From 0eade4697bd76b9cc99e74777531ba00df558ff5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:45:24 +0100 Subject: Add unit test for mentions antispam rule --- tests/bot/rules/test_mentions.py | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/bot/rules/test_mentions.py (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py new file mode 100644 index 000000000..520184c2f --- /dev/null +++ b/tests/bot/rules/test_mentions.py @@ -0,0 +1,98 @@ +import unittest +from typing import List, NamedTuple, Tuple + +from bot.rules import mentions +from tests.helpers import async_test + + +class FakeMessage(NamedTuple): + author: str + mentions: List[None] + + +class Case(NamedTuple): + recent_messages: List[FakeMessage] + relevant_messages: Tuple[FakeMessage] + culprit: str + total_mentions: int + + +def msg(author: str, total_mentions: int) -> FakeMessage: + return FakeMessage(author=author, mentions=[None] * 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)], + (msg("bob", 3),), + ("bob",), + 3 + ), + Case( + [msg("alice", 2), msg("alice", 0), msg("alice", 1)], + (msg("alice", 2), msg("alice", 0), msg("alice", 1)), + ("alice",), + 3 + ), + Case( + [msg("bob", 2), msg("alice", 3), msg("bob", 2)], + (msg("bob", 2), msg("bob", 2)), + ("bob",), + 4 + ) + ) + + for recent_messages, relevant_messages, culprit, total_mentions in cases: + last_message = recent_messages[0] + + 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 + ) -- cgit v1.2.3 From 187d419810759992e19a792fd746f26960f3831a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:46:34 +0100 Subject: Add missing docstring --- tests/bot/rules/test_mentions.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 520184c2f..987a42c0a 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -18,6 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_mentions: int) -> FakeMessage: + """Makes a message with `total_mentions` mentions.""" return FakeMessage(author=author, mentions=[None] * total_mentions) -- cgit v1.2.3 From 32caead77e4bad04689892a29005faa3ffde2e83 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:50:54 +0100 Subject: Adjust docstring asterisk to backtick for consistency --- tests/bot/rules/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index be832843b..40336beb0 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -18,7 +18,7 @@ class Case(NamedTuple): def msg(author: str, total_links: int) -> FakeMessage: - """Makes a message with *total_links* links.""" + """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) return FakeMessage(author=author, content=content) -- cgit v1.2.3 From b87a98c7749edfeb9fbc368f2bf9a7ecb9434662 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 10:54:26 +0100 Subject: Use range to build mock mentions list --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 987a42c0a..e1a971dbb 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -19,7 +19,7 @@ class Case(NamedTuple): def msg(author: str, total_mentions: int) -> FakeMessage: """Makes a message with `total_mentions` mentions.""" - return FakeMessage(author=author, mentions=[None] * total_mentions) + return FakeMessage(author=author, mentions=list(range(total_mentions))) class TestMentions(unittest.TestCase): -- cgit v1.2.3 From b1c6b4e20578395a5766ac116fd4def1df777de7 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 13:49:55 +0100 Subject: Adjust type hint to correctly represent internal type --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index e1a971dbb..08dd1d6d5 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -7,7 +7,7 @@ from tests.helpers import async_test class FakeMessage(NamedTuple): author: str - mentions: List[None] + mentions: List[int] class Case(NamedTuple): -- cgit v1.2.3 From f915782192fd7b631b23fa31e524cacdc8e72614 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 16:25:23 +0100 Subject: Use MockMessage instead of custom FakeMessage --- tests/bot/rules/test_mentions.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 08dd1d6d5..e377f2164 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,24 +2,18 @@ import unittest from typing import List, NamedTuple, Tuple from bot.rules import mentions -from tests.helpers import async_test - - -class FakeMessage(NamedTuple): - author: str - mentions: List[int] +from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[FakeMessage] - relevant_messages: Tuple[FakeMessage] - culprit: str + recent_messages: List[MockMessage, ...] + culprit: Tuple[str] total_mentions: int -def msg(author: str, total_mentions: int) -> FakeMessage: +def msg(author: str, total_mentions: int) -> MockMessage: """Makes a message with `total_mentions` mentions.""" - return FakeMessage(author=author, mentions=list(range(total_mentions))) + return MockMessage(author=author, mentions=list(range(total_mentions))) class TestMentions(unittest.TestCase): @@ -59,26 +53,28 @@ class TestMentions(unittest.TestCase): cases = ( Case( [msg("bob", 3)], - (msg("bob", 3),), ("bob",), 3 ), Case( [msg("alice", 2), msg("alice", 0), msg("alice", 1)], - (msg("alice", 2), msg("alice", 0), msg("alice", 1)), ("alice",), 3 ), Case( [msg("bob", 2), msg("alice", 3), msg("bob", 2)], - (msg("bob", 2), msg("bob", 2)), ("bob",), 4 ) ) - for recent_messages, relevant_messages, culprit, total_mentions in cases: + 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, -- cgit v1.2.3 From 16e6c36c6b370300ef7507d2e01421ad0d83f407 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 9 Nov 2019 16:27:02 +0100 Subject: Adjust incorrect type hint --- tests/bot/rules/test_mentions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index e377f2164..ad49ead32 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -6,7 +6,7 @@ from tests.helpers import MockMessage, async_test class Case(NamedTuple): - recent_messages: List[MockMessage, ...] + recent_messages: List[MockMessage] culprit: Tuple[str] total_mentions: int -- cgit v1.2.3 From 2a9b1fc24ffe9679a565c0f9f4678357e9c80e44 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:17:18 +0100 Subject: Adjust links rule to use proper MockMessage --- tests/bot/rules/test_links.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 40336beb0..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: +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, -- cgit v1.2.3 From a2617d197f4863123caa33076d89b7612a902d60 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:42:25 +0100 Subject: Adjust attachments rule to use MockMessage, restructure test cases --- tests/bot/rules/test_attachments.py | 43 ++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 4bb0acf7c..2f8294922 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,26 +1,17 @@ import asyncio import unittest -from dataclasses import dataclass -from typing import Any, List from bot.rules import attachments +from tests.helpers import MockMessage -# 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) -> FakeMessage: - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) +def msg(total_attachments: int) -> MockMessage: + """Builds a message with `total_attachments` attachments.""" + return MockMessage(author='lemon', 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): """Messages without too many attachments are allowed as-is.""" @@ -38,13 +29,25 @@ class AttachmentRuleTests(unittest.TestCase): 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), + ([msg(4), msg(0), msg(6)], 10), + ([msg(6)], 6), + ([msg(1)] * 6, 6), ) - for messages, relevant_messages, total in cases: - with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): - last_message, *recent_messages = messages + for messages, total in cases: + last_message, *recent_messages = messages + relevant_messages = [last_message] + [ + 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=total + ): coro = attachments.apply(last_message, recent_messages, {'max': 5}) self.assertEqual( asyncio.run(coro), -- cgit v1.2.3 From dd098d91e35c2e333af14919d7405fe47f298ac2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 13 Nov 2019 22:48:59 +0100 Subject: Use async_test helper to simplify coro testing --- tests/bot/rules/test_attachments.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 2f8294922..770dd3201 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,8 +1,7 @@ -import asyncio import unittest from bot.rules import attachments -from tests.helpers import MockMessage +from tests.helpers import MockMessage, async_test def msg(total_attachments: int) -> MockMessage: @@ -13,7 +12,8 @@ def msg(total_attachments: int) -> MockMessage: class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" - def test_allows_messages_without_too_many_attachments(self): + @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)), @@ -22,17 +22,23 @@ class AttachmentRuleTests(unittest.TestCase): ) 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)) + with self.subTest( + last_message=last_message, + recent_messages=recent_messages + ): + self.assertIsNone( + await attachments.apply(last_message, recent_messages, {'max': 5}) + ) - 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)], 10), ([msg(6)], 6), ([msg(1)] * 6, 6), ) + for messages, total in cases: last_message, *recent_messages = messages relevant_messages = [last_message] + [ @@ -48,8 +54,7 @@ class AttachmentRuleTests(unittest.TestCase): relevant_messages=relevant_messages, total=total ): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) self.assertEqual( - asyncio.run(coro), + await attachments.apply(last_message, recent_messages, {'max': 5}), (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) ) -- cgit v1.2.3 From eef447a2c4e237a56b8f3cb72ee3e4bc54e7961c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:16:23 +0100 Subject: Adjust attachments rule unit test to correcty build the arguments for the tested rule --- tests/bot/rules/test_attachments.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 770dd3201..a43741fcc 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -21,7 +21,9 @@ class AttachmentRuleTests(unittest.TestCase): (msg(0),), ) - for last_message, *recent_messages in cases: + for recent_messages in cases: + last_message = recent_messages[0] + with self.subTest( last_message=last_message, recent_messages=recent_messages @@ -39,14 +41,16 @@ class AttachmentRuleTests(unittest.TestCase): ([msg(1)] * 6, 6), ) - for messages, total in cases: - last_message, *recent_messages = messages - relevant_messages = [last_message] + [ + for recent_messages, total 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 - ] + if ( + msg.author == last_message.author + and len(msg.attachments) > 0 + ) + ) with self.subTest( last_message=last_message, -- cgit v1.2.3 From 37b526f372ebc981f5691c5aca1ca8c721da77f6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:18:57 +0100 Subject: Hold recent_messages in a list to respect type hint, set config in setUp --- tests/bot/rules/test_attachments.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index a43741fcc..fa6b63654 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -12,13 +12,16 @@ def msg(total_attachments: int) -> MockMessage: class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" + 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(0), msg(0), msg(0)], + [msg(2), msg(2)], + [msg(0)], ) for recent_messages in cases: @@ -29,7 +32,7 @@ class AttachmentRuleTests(unittest.TestCase): recent_messages=recent_messages ): self.assertIsNone( - await attachments.apply(last_message, recent_messages, {'max': 5}) + await attachments.apply(last_message, recent_messages, self.config) ) @async_test @@ -59,6 +62,6 @@ class AttachmentRuleTests(unittest.TestCase): total=total ): self.assertEqual( - await attachments.apply(last_message, recent_messages, {'max': 5}), + await attachments.apply(last_message, recent_messages, self.config), (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) ) -- cgit v1.2.3 From 01731a8873f13cc8a85d08147941ffba7284cf20 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:33:12 +0100 Subject: Make complex test cases namedtuples, recognize between various authors, pass config to subTest --- tests/bot/rules/test_attachments.py | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index fa6b63654..d8d1b341f 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,12 +1,19 @@ import unittest +from typing import List, NamedTuple, Tuple from bot.rules import attachments from tests.helpers import MockMessage, async_test -def msg(total_attachments: int) -> MockMessage: +class Case(NamedTuple): + recent_messages: List[MockMessage] + culprit: Tuple[str] + total_attachments: int + + +def msg(author: str, total_attachments: int) -> MockMessage: """Builds a message with `total_attachments` attachments.""" - return MockMessage(author='lemon', attachments=list(range(total_attachments))) + return MockMessage(author=author, attachments=list(range(total_attachments))) class AttachmentRuleTests(unittest.TestCase): @@ -19,9 +26,9 @@ class AttachmentRuleTests(unittest.TestCase): 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 recent_messages in cases: @@ -29,7 +36,8 @@ class AttachmentRuleTests(unittest.TestCase): with self.subTest( last_message=last_message, - recent_messages=recent_messages + recent_messages=recent_messages, + config=self.config ): self.assertIsNone( await attachments.apply(last_message, recent_messages, self.config) @@ -39,12 +47,29 @@ class AttachmentRuleTests(unittest.TestCase): 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)], 10), - ([msg(6)], 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 recent_messages, total in cases: + for recent_messages, culprit, total_attachments in cases: last_message = recent_messages[0] relevant_messages = tuple( msg @@ -59,9 +84,15 @@ class AttachmentRuleTests(unittest.TestCase): last_message=last_message, recent_messages=recent_messages, relevant_messages=relevant_messages, - total=total + total_attachments=total_attachments, + config=self.config ): + desired_output = ( + f"sent {total_attachments} attachments in {self.config['max']}s", + culprit, + relevant_messages + ) self.assertEqual( await attachments.apply(last_message, recent_messages, self.config), - (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + desired_output ) -- cgit v1.2.3 From c74a6c4fb16052c00041b94c3a3e2ef10efe9827 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 14 Nov 2019 20:34:00 +0100 Subject: Specify assertion to be a tuple comparison --- tests/bot/rules/test_attachments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index d8d1b341f..d7187f315 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -92,7 +92,7 @@ class AttachmentRuleTests(unittest.TestCase): culprit, relevant_messages ) - self.assertEqual( + self.assertTupleEqual( await attachments.apply(last_message, recent_messages, self.config), desired_output ) -- cgit v1.2.3 From e07cf7342184b769d8c0655bc9b84be02809319a Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Wed, 4 Dec 2019 23:38:46 +0700 Subject: Added `unittest` for `bot.utils.time` --- tests/bot/utils/test_time.py | 87 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/bot/utils/test_time.py (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py new file mode 100644 index 000000000..0ef59292e --- /dev/null +++ b/tests/bot/utils/test_time.py @@ -0,0 +1,87 @@ +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 setUp(self): + pass + + def test_humanize_delta(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'), + + # 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'), + ) + + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + + def test_humanize_delta_raises_for_invalid_max_units(self): + test_cases = (-1, 0) + + for max_units in test_cases: + with 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.""" + test_cases = ( + ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + ) + + for stamp, expected in test_cases: + self.assertEqual(time.parse_rfc1123(stamp), expected) + + @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 + assert asyncio.run(time.wait_until(then, start)) is None + + mock.assert_called_once_with(10 * 60) + + def test_format_infraction_with_duration(self): + """Testing format_infraction_with_duration.""" + 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:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, + '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), + ('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:09:00Z', datetime(2019, 4, 25, 20, 15), 6, + '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)'), + ('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: + self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) -- cgit v1.2.3 From b17dbe5e3e0dfa6ae44d660924455f709abefd0d Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:34:58 +0700 Subject: Splitting test cases for `humanize_delta` into proper, independent tests. --- tests/bot/utils/test_time.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 0ef59292e..5e5f2bf2f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -15,18 +15,20 @@ class TimeTests(unittest.TestCase): def setUp(self): pass - def test_humanize_delta(self): - """Testing humanize delta.""" + def test_humanize_delta_handle_unknown_units(self): + """humanize_delta should be able to handle unknown units, and will not abort.""" 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'), - # 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'), + ) + for delta, precision, max_units, expected in test_cases: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + + def test_humanize_delta_handle_high_units(self): + """humanize_delta should be able to handle very high units.""" + test_cases = ( # 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'), @@ -35,6 +37,18 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + def test_humanize_delta_should_work_normally(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: + self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + def test_humanize_delta_raises_for_invalid_max_units(self): test_cases = (-1, 0) -- cgit v1.2.3 From 0aee728d6d23ef24f51834f39016f938f3f1b8a9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:36:29 +0700 Subject: Added missing docstring for `test_humanize_delta_raises_for_invalid_max_units` --- tests/bot/utils/test_time.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 5e5f2bf2f..a929bee89 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -50,6 +50,7 @@ class TimeTests(unittest.TestCase): 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: -- cgit v1.2.3 From beed21355e7f0e25b69637768843c53d510b8969 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:40:38 +0700 Subject: Changed `assert` to `self.assertIs` for `test_wait_until` --- tests/bot/utils/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index a929bee89..0afabe400 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -74,7 +74,7 @@ class TimeTests(unittest.TestCase): then = datetime(2019, 1, 1, 0, 10) # No return value - assert asyncio.run(time.wait_until(then, start)) is None + self.assertIs(asyncio.run(time.wait_until(then, start)), None) mock.assert_called_once_with(10 * 60) -- cgit v1.2.3 From ccdd8363d75846f0841791ba54763dae28243c62 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:45:36 +0700 Subject: Splitting test cases for `format_infraction_with_duration` into proper, independent tests. --- tests/bot/utils/test_time.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 0afabe400..2a2a707d8 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -37,7 +37,7 @@ class TimeTests(unittest.TestCase): for delta, precision, max_units, expected in test_cases: self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) - def test_humanize_delta_should_work_normally(self): + def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" test_cases = ( (relativedelta(days=2), 'seconds', 1, '2 days'), @@ -78,18 +78,38 @@ class TimeTests(unittest.TestCase): mock.assert_called_once_with(10 * 60) - def test_format_infraction_with_duration(self): - """Testing format_infraction_with_duration.""" + def test_format_infraction_with_duration_none_expiry(self): + """format_infraction_with_duration should work for None expiry.""" + self.assertEqual(time.format_infraction_with_duration(None), None) + + # To make sure that date_from and max_units are not touched + self.assertEqual(time.format_infraction_with_duration(None, date_from='Why hello there!'), None) + self.assertEqual(time.format_infraction_with_duration(None, max_units=float('inf')), None) + self.assertEqual( + time.format_infraction_with_duration(None, date_from='Why hello there!', max_units=float('inf')), + None + ) + + def test_format_infraction_with_duration_custom_units(self): + """format_infraction_with_duration should work for custom max_units.""" + self.assertEqual( + time.format_infraction_with_duration('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)' + ) + + self.assertEqual( + time.format_infraction_with_duration('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)' + ) + + 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:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), ('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:09:00Z', datetime(2019, 4, 25, 20, 15), 6, - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)'), ('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)'), -- cgit v1.2.3 From fa66195dbb6f79bb7174084835499a61e8cb03a3 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 00:52:15 +0700 Subject: Introduced test for `test_format_infraction`, refactored `test_parse_rfc1123`, fixed typo. --- tests/bot/utils/test_time.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 2a2a707d8..09fb824e4 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -50,7 +50,7 @@ class TimeTests(unittest.TestCase): 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.""" + """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" test_cases = (-1, 0) for max_units in test_cases: @@ -60,12 +60,14 @@ class TimeTests(unittest.TestCase): def test_parse_rfc1123(self): """Testing parse_rfc1123.""" - test_cases = ( - ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + self.assertEqual( + time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), + datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) ) - for stamp, expected in test_cases: - self.assertEqual(time.parse_rfc1123(stamp), expected) + 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): -- cgit v1.2.3 From 5e0b19ae841f3f355931ad331f7aa861fbafc4d9 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:05:41 +0700 Subject: Added `self.subTest` for tests with multiple test cases & simplified single test case tests. --- tests/bot/utils/test_time.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 09fb824e4..c47a306f0 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -17,25 +17,15 @@ class TimeTests(unittest.TestCase): def test_humanize_delta_handle_unknown_units(self): """humanize_delta should be able to handle unknown units, and will not abort.""" - test_cases = ( - # 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'), - ) - - for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + # 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.""" - test_cases = ( - # 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'), - ) - - for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + # 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.""" @@ -47,14 +37,15 @@ class TimeTests(unittest.TestCase): ) for delta, precision, max_units, expected in test_cases: - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + 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.assertRaises(ValueError) as error: + 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') @@ -121,4 +112,5 @@ class TimeTests(unittest.TestCase): ) for expiry, date_from, max_units, expected in test_cases: - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + 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) -- cgit v1.2.3 From db341d927aab42c2e874cb499ab1c2e6c0e7647b Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:17:56 +0700 Subject: Moved all individual test cases into iterables and test with `self.subTest` context manager. --- tests/bot/utils/test_time.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index c47a306f0..25cd3f69f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -73,27 +73,31 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_none_expiry(self): """format_infraction_with_duration should work for None expiry.""" - self.assertEqual(time.format_infraction_with_duration(None), None) + test_cases = ( + (None, None, None, None), - # To make sure that date_from and max_units are not touched - self.assertEqual(time.format_infraction_with_duration(None, date_from='Why hello there!'), None) - self.assertEqual(time.format_infraction_with_duration(None, max_units=float('inf')), None) - self.assertEqual( - time.format_infraction_with_duration(None, date_from='Why hello there!', max_units=float('inf')), - 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.""" - self.assertEqual( - time.format_infraction_with_duration('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)' + 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)') ) - self.assertEqual( - time.format_infraction_with_duration('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.""" -- cgit v1.2.3 From 323306776b0312e2a32ada213a35159311a93a7f Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Thu, 5 Dec 2019 01:42:23 +0700 Subject: Removed `setUp()` from `TimeTests` since it is not being used for anything. --- tests/bot/utils/test_time.py | 3 --- 1 file changed, 3 deletions(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 25cd3f69f..7f55dc3ec 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -12,9 +12,6 @@ from tests.helpers import AsyncMock class TimeTests(unittest.TestCase): """Test helper functions in bot.utils.time.""" - def setUp(self): - pass - 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 -- cgit v1.2.3 From 520346d0b472e5cb6c9091a8323b871d2e3821cc Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:18:49 +0700 Subject: Added tests for `until_expiration` Similar to `format_infraction_with_duration` ( if not outright copying it ), added 3 tests for `until_expiration`: - None `expiry`. - Custom `max_units`. - Normal use cases. --- tests/bot/utils/test_time.py | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 7f55dc3ec..bd04de28b 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -115,3 +115,48 @@ class TimeTests(unittest.TestCase): 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 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, 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) -- cgit v1.2.3 From 66d4b93593b95bfa6999b70aca53328d83710c44 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Fri, 13 Dec 2019 09:29:06 +0700 Subject: Fixed a typo ( due to poor copy pasta and eyeballing skills ) --- tests/bot/utils/test_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index bd04de28b..69f35f2f5 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -121,7 +121,7 @@ class TimeTests(unittest.TestCase): test_cases = ( (None, None, None, None), - # To make sure that date_from and max_units are not touched + # 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), -- cgit v1.2.3 From 1f17d0ed894ee6a4c6a9c703d03598d734ffeac2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:20:17 +0100 Subject: Add unit test case for burst antispam rule --- tests/bot/rules/test_burst.py | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/bot/rules/test_burst.py (limited to 'tests') diff --git a/tests/bot/rules/test_burst.py b/tests/bot/rules/test_burst.py new file mode 100644 index 000000000..4da0cc78e --- /dev/null +++ b/tests/bot/rules/test_burst.py @@ -0,0 +1,69 @@ +import unittest + +from bot.rules import burst +from tests.helpers import MockMessage, async_test + + +def make_msg(author: str) -> MockMessage: + """Init a MockMessage instance with author set to `author`. + + This serves as a shorthand / alias to keep the test cases visually clean. + """ + return MockMessage(author=author) + + +class BurstRuleTests(unittest.TestCase): + """Tests the `burst` antispam rule.""" + + def setUp(self): + self.config = {"max": 2, "interval": 10} + + @async_test + async def test_allows_messages_within_limit(self): + """Cases which do not violate the rule.""" + cases = ( + [make_msg("bob"), make_msg("bob")], + [make_msg("bob"), make_msg("alice"), make_msg("bob")], + ) + + 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 burst.apply(last_message, recent_messages, self.config)) + + @async_test + async def test_disallows_messages_beyond_limit(self): + """Cases where the amount of messages exceeds the limit, triggering the rule.""" + cases = ( + ( + [make_msg("bob"), make_msg("bob"), make_msg("bob")], + "bob", + 3, + ), + ( + [make_msg("bob"), make_msg("bob"), make_msg("alice"), make_msg("bob")], + "bob", + 3, + ), + ) + + for recent_messages, culprit, total_msgs in cases: + last_message = recent_messages[0] + relevant_messages = tuple(msg for msg in recent_messages if msg.author == culprit) + expected_output = ( + f"sent {total_msgs} messages in {self.config['interval']}s", + (culprit,), + relevant_messages, + ) + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config, + expected_output=expected_output, + ): + self.assertTupleEqual( + await burst.apply(last_message, recent_messages, self.config), + expected_output, + ) -- cgit v1.2.3 From b0713be662fae40f58104923534b563b19e79f1d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:20:47 +0100 Subject: Add unit test case for burst shared antispam rule --- tests/bot/rules/test_burst_shared.py | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/bot/rules/test_burst_shared.py (limited to 'tests') diff --git a/tests/bot/rules/test_burst_shared.py b/tests/bot/rules/test_burst_shared.py new file mode 100644 index 000000000..1f5662eff --- /dev/null +++ b/tests/bot/rules/test_burst_shared.py @@ -0,0 +1,65 @@ +import unittest + +from bot.rules import burst_shared +from tests.helpers import MockMessage, async_test + + +def make_msg(author: str) -> MockMessage: + """Init a MockMessage instance with the passed arg. + + This serves as a shorthand / alias to keep the test cases visually clean. + """ + return MockMessage(author=author) + + +class BurstSharedRuleTests(unittest.TestCase): + """Tests the `burst_shared` antispam rule.""" + + def setUp(self): + self.config = {"max": 2, "interval": 10} + + @async_test + async def test_allows_messages_within_limit(self): + """Cases that do not violate the rule. + + There really isn't more to test here than a single case. + """ + recent_messages = [make_msg("spongebob"), make_msg("patrick")] + last_message = recent_messages[0] + + self.assertIsNone(await burst_shared.apply(last_message, recent_messages, self.config)) + + @async_test + async def test_disallows_messages_beyond_limit(self): + """Cases where the amount of messages exceeds the limit, triggering the rule.""" + cases = ( + ( + [make_msg("bob"), make_msg("bob"), make_msg("bob")], + {"bob"}, + 3, + ), + ( + [make_msg("bob"), make_msg("bob"), make_msg("alice"), make_msg("bob")], + {"bob", "alice"}, + 4, + ), + ) + + for recent_messages, culprits, total_msgs in cases: + last_message = recent_messages[0] + expected_output = ( + f"sent {total_msgs} messages in {self.config['interval']}s", + culprits, + recent_messages, + ) + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config, + expected_output=expected_output, + ): + self.assertTupleEqual( + await burst_shared.apply(last_message, recent_messages, self.config), + expected_output, + ) -- cgit v1.2.3 From f15f3184d6e0c2ad4db303528a1954d589d93900 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:21:08 +0100 Subject: Add unit test case for chars antispam rule --- tests/bot/rules/test_chars.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/bot/rules/test_chars.py (limited to 'tests') diff --git a/tests/bot/rules/test_chars.py b/tests/bot/rules/test_chars.py new file mode 100644 index 000000000..f466a898e --- /dev/null +++ b/tests/bot/rules/test_chars.py @@ -0,0 +1,75 @@ +import unittest + +from bot.rules import chars +from tests.helpers import MockMessage, async_test + + +def make_msg(author: str, n_chars: int) -> MockMessage: + """Build a message with arbitrary content of `n_chars` length.""" + return MockMessage(author=author, content="A" * n_chars) + + +class CharsRuleTests(unittest.TestCase): + """Tests the `chars` antispam rule.""" + + def setUp(self): + self.config = { + "max": 20, # Max allowed sum of chars per user + "interval": 10, + } + + @async_test + async def test_allows_messages_within_limit(self): + """Cases with a total amount of chars within limit.""" + cases = ( + [make_msg("bob", 0)], + [make_msg("bob", 20)], + [make_msg("bob", 15), make_msg("alice", 15)], + ) + + 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 chars.apply(last_message, recent_messages, self.config)) + + @async_test + async def test_disallows_messages_beyond_limit(self): + """Cases where the total amount of chars exceeds the limit, triggering the rule.""" + cases = ( + ( + [make_msg("bob", 21)], + "bob", + 21, + ), + ( + [make_msg("bob", 15), make_msg("bob", 15)], + "bob", + 30, + ), + ( + [make_msg("alice", 15), make_msg("bob", 20), make_msg("alice", 15)], + "alice", + 30, + ), + ) + + for recent_messages, culprit, total_chars in cases: + last_message = recent_messages[0] + relevant_messages = tuple(msg for msg in recent_messages if msg.author == culprit) + expected_output = ( + f"sent {total_chars} characters in {self.config['interval']}s", + (culprit,), + relevant_messages, + ) + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config, + expected_output=expected_output, + ): + self.assertTupleEqual( + await chars.apply(last_message, recent_messages, self.config), + expected_output, + ) -- cgit v1.2.3 From 4bfe30def137921a4320208b66472dc97c5bd298 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:21:28 +0100 Subject: Add unit test case for discord emojis antispam rule --- tests/bot/rules/test_discord_emojis.py | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/bot/rules/test_discord_emojis.py (limited to 'tests') diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py new file mode 100644 index 000000000..1c56c9563 --- /dev/null +++ b/tests/bot/rules/test_discord_emojis.py @@ -0,0 +1,68 @@ +import unittest + +from bot.rules import discord_emojis +from tests.helpers import MockMessage, async_test + +discord_emoji = "<:abcd:1234>" # Discord emojis follow the format <:name:id> + + +def make_msg(author: str, n_emojis: int) -> MockMessage: + """Build a MockMessage instance with content containing `n_emojis` arbitrary emojis.""" + return MockMessage(author=author, content=discord_emoji * n_emojis) + + +class DiscordEmojisRuleTests(unittest.TestCase): + """Tests for the `discord_emojis` antispam rule.""" + + def setUp(self): + self.config = {"max": 2, "interval": 10} + + @async_test + async def test_allows_messages_within_limit(self): + """Cases with a total amount of discord emojis within limit.""" + cases = ( + [make_msg("bob", 2)], + [make_msg("alice", 1), make_msg("bob", 2), make_msg("alice", 1)], + ) + + 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 discord_emojis.apply(last_message, recent_messages, self.config)) + + @async_test + async def test_disallows_messages_beyond_limit(self): + """Cases with more than the allowed amount of discord emojis.""" + cases = ( + ( + [make_msg("bob", 3)], + "bob", + 3, + ), + ( + [make_msg("alice", 2), make_msg("bob", 2), make_msg("alice", 2)], + "alice", + 4, + ), + ) + + for recent_messages, culprit, total_emojis in cases: + last_message = recent_messages[0] + relevant_messages = tuple(msg for msg in recent_messages if msg.author == culprit) + expected_output = ( + f"sent {total_emojis} emojis in {self.config['interval']}s", + (culprit,), + relevant_messages, + ) + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config, + expected_output=expected_output, + ): + self.assertTupleEqual( + await discord_emojis.apply(last_message, recent_messages, self.config), + expected_output, + ) -- cgit v1.2.3 From f32ffaffd92b0521adec42432121aefb3d596b0e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:21:42 +0100 Subject: Add unit test case for role mentions antispam rule --- tests/bot/rules/test_role_mentions.py | 66 +++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/bot/rules/test_role_mentions.py (limited to 'tests') diff --git a/tests/bot/rules/test_role_mentions.py b/tests/bot/rules/test_role_mentions.py new file mode 100644 index 000000000..6377ffbc8 --- /dev/null +++ b/tests/bot/rules/test_role_mentions.py @@ -0,0 +1,66 @@ +import unittest + +from bot.rules import role_mentions +from tests.helpers import MockMessage, async_test + + +def make_msg(author: str, n_mentions: int) -> MockMessage: + """Build a MockMessage instance with `n_mentions` role mentions.""" + return MockMessage(author=author, role_mentions=[None] * n_mentions) + + +class RoleMentionsRuleTests(unittest.TestCase): + """Tests for the `role_mentions` antispam rule.""" + + def setUp(self): + self.config = {"max": 2, "interval": 10} + + @async_test + async def test_allows_messages_within_limit(self): + """Cases with a total amount of role mentions within limit.""" + cases = ( + [make_msg("bob", 2)], + [make_msg("bob", 1), make_msg("alice", 1), make_msg("bob", 1)], + ) + + 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 role_mentions.apply(last_message, recent_messages, self.config)) + + @async_test + async def test_disallows_messages_beyond_limit(self): + """Cases with more than the allowed amount of role mentions.""" + cases = ( + ( + [make_msg("bob", 3)], + "bob", + 3, + ), + ( + [make_msg("alice", 2), make_msg("bob", 2), make_msg("alice", 2)], + "alice", + 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 == culprit) + expected_output = ( + f"sent {total_mentions} role mentions in {self.config['interval']}s", + (culprit,), + relevant_messages, + ) + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + config=self.config, + expected_output=expected_output, + ): + self.assertTupleEqual( + await role_mentions.apply(last_message, recent_messages, self.config), + expected_output, + ) -- cgit v1.2.3 From 4199b12da2dedd8720cf521a75a821811af59cfd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:30:12 +0100 Subject: Fix incorrect config key in attachments antispam rule The rule was incorrectly printing out the maximum amount of allowed attachments instead of the configured interval. This commit also adjusts the rule's unit test case. --- bot/rules/attachments.py | 2 +- tests/bot/rules/test_attachments.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index 00bb2a949..8903c385c 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -19,7 +19,7 @@ async def apply( if total_recent_attachments > config['max']: return ( - f"sent {total_recent_attachments} attachments in {config['max']}s", + f"sent {total_recent_attachments} attachments in {config['interval']}s", (last_message.author,), relevant_messages ) diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index d7187f315..0af5ff0dc 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -20,7 +20,7 @@ class AttachmentRuleTests(unittest.TestCase): """Tests applying the `attachments` antispam rule.""" def setUp(self): - self.config = {"max": 5} + self.config = {"max": 5, "interval": 10} @async_test async def test_allows_messages_without_too_many_attachments(self): @@ -88,7 +88,7 @@ class AttachmentRuleTests(unittest.TestCase): config=self.config ): desired_output = ( - f"sent {total_attachments} attachments in {self.config['max']}s", + f"sent {total_attachments} attachments in {self.config['interval']}s", culprit, relevant_messages ) -- cgit v1.2.3 From aedbd6f697d6c66b3dbefa894795c86c6d0a2628 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 26 Jan 2020 22:34:52 +0100 Subject: Refactor msg helper function name to make_msg The name msg is less descriptive and creates a needless name conflict in local gen exp. --- tests/bot/rules/test_attachments.py | 16 ++++++++-------- tests/bot/rules/test_links.py | 18 +++++++++--------- tests/bot/rules/test_mentions.py | 16 ++++++++-------- 3 files changed, 25 insertions(+), 25 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 0af5ff0dc..419336417 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -11,7 +11,7 @@ class Case(NamedTuple): total_attachments: int -def msg(author: str, total_attachments: int) -> MockMessage: +def make_msg(author: str, total_attachments: int) -> MockMessage: """Builds a message with `total_attachments` attachments.""" return MockMessage(author=author, attachments=list(range(total_attachments))) @@ -26,9 +26,9 @@ class AttachmentRuleTests(unittest.TestCase): async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( - [msg("bob", 0), msg("bob", 0), msg("bob", 0)], - [msg("bob", 2), msg("bob", 2)], - [msg("bob", 2), msg("alice", 2), msg("bob", 2)], + [make_msg("bob", 0), make_msg("bob", 0), make_msg("bob", 0)], + [make_msg("bob", 2), make_msg("bob", 2)], + [make_msg("bob", 2), make_msg("alice", 2), make_msg("bob", 2)], ) for recent_messages in cases: @@ -48,22 +48,22 @@ class AttachmentRuleTests(unittest.TestCase): """Messages with too many attachments trigger the rule.""" cases = ( Case( - [msg("bob", 4), msg("bob", 0), msg("bob", 6)], + [make_msg("bob", 4), make_msg("bob", 0), make_msg("bob", 6)], ("bob",), 10 ), Case( - [msg("bob", 4), msg("alice", 6), msg("bob", 2)], + [make_msg("bob", 4), make_msg("alice", 6), make_msg("bob", 2)], ("bob",), 6 ), Case( - [msg("alice", 6)], + [make_msg("alice", 6)], ("alice",), 6 ), ( - [msg("alice", 1) for _ in range(6)], + [make_msg("alice", 1) for _ in range(6)], ("alice",), 6 ), diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 02a5d5501..b77e01c84 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -11,7 +11,7 @@ class Case(NamedTuple): total_links: int -def msg(author: str, total_links: int) -> MockMessage: +def make_msg(author: str, total_links: int) -> MockMessage: """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) return MockMessage(author=author, content=content) @@ -30,11 +30,11 @@ class LinksTests(unittest.TestCase): async def test_links_within_limit(self): """Messages with an allowed amount of links.""" cases = ( - [msg("bob", 0)], - [msg("bob", 2)], - [msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 - [msg("bob", 1), msg("bob", 1)], - [msg("bob", 2), msg("alice", 2)] # Only messages from latest author count + [make_msg("bob", 0)], + [make_msg("bob", 2)], + [make_msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 + [make_msg("bob", 1), make_msg("bob", 1)], + [make_msg("bob", 2), make_msg("alice", 2)] # Only messages from latest author count ) for recent_messages in cases: @@ -54,17 +54,17 @@ class LinksTests(unittest.TestCase): """Messages with a a higher than allowed amount of links.""" cases = ( Case( - [msg("bob", 1), msg("bob", 2)], + [make_msg("bob", 1), make_msg("bob", 2)], ("bob",), 3 ), Case( - [msg("alice", 1), msg("alice", 1), msg("alice", 1)], + [make_msg("alice", 1), make_msg("alice", 1), make_msg("alice", 1)], ("alice",), 3 ), Case( - [msg("alice", 2), msg("bob", 3), msg("alice", 1)], + [make_msg("alice", 2), make_msg("bob", 3), make_msg("alice", 1)], ("alice",), 3 ) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index ad49ead32..43211f097 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -11,7 +11,7 @@ class Case(NamedTuple): total_mentions: int -def msg(author: str, total_mentions: int) -> MockMessage: +def make_msg(author: str, total_mentions: int) -> MockMessage: """Makes a message with `total_mentions` mentions.""" return MockMessage(author=author, mentions=list(range(total_mentions))) @@ -29,10 +29,10 @@ class TestMentions(unittest.TestCase): 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)] + [make_msg("bob", 0)], + [make_msg("bob", 2)], + [make_msg("bob", 1), make_msg("bob", 1)], + [make_msg("bob", 1), make_msg("alice", 2)] ) for recent_messages in cases: @@ -52,17 +52,17 @@ class TestMentions(unittest.TestCase): """Messages with a higher than allowed amount of mentions.""" cases = ( Case( - [msg("bob", 3)], + [make_msg("bob", 3)], ("bob",), 3 ), Case( - [msg("alice", 2), msg("alice", 0), msg("alice", 1)], + [make_msg("alice", 2), make_msg("alice", 0), make_msg("alice", 1)], ("alice",), 3 ), Case( - [msg("bob", 2), msg("alice", 3), msg("bob", 2)], + [make_msg("bob", 2), make_msg("alice", 3), make_msg("bob", 2)], ("bob",), 4 ) -- cgit v1.2.3 From d169fbf9204b8a35ad09ab0141eedd873f8bdddf Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 30 Jan 2020 18:17:12 +0100 Subject: Add additional resources to the test readme --- tests/README.md | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index d052de2f6..c3551bd54 100644 --- a/tests/README.md +++ b/tests/README.md @@ -212,3 +212,9 @@ 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 + +* [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/) -- cgit v1.2.3 From e518dc2193f0f1ed31217e6097c2c6cdbc36e0e1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 30 Jan 2020 19:04:21 +0100 Subject: Merge the note with the additional resources section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the link to Ned Batchelder’s talk and link the note to the section --- tests/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index c3551bd54..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 @@ -215,6 +215,7 @@ The answer to this is that we also need to make sure that the individual parts c ## 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/) -- cgit v1.2.3 From 8456331140bdd6bdc1d2c8bf395b69febef45ad8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 31 Jan 2020 20:14:43 +0100 Subject: Adjust multi-line docstrings to prevailing style --- tests/bot/rules/test_burst.py | 3 ++- tests/bot/rules/test_burst_shared.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_burst.py b/tests/bot/rules/test_burst.py index 4da0cc78e..afcc5554d 100644 --- a/tests/bot/rules/test_burst.py +++ b/tests/bot/rules/test_burst.py @@ -5,7 +5,8 @@ from tests.helpers import MockMessage, async_test def make_msg(author: str) -> MockMessage: - """Init a MockMessage instance with author set to `author`. + """ + Init a MockMessage instance with author set to `author`. This serves as a shorthand / alias to keep the test cases visually clean. """ diff --git a/tests/bot/rules/test_burst_shared.py b/tests/bot/rules/test_burst_shared.py index 1f5662eff..401e0b666 100644 --- a/tests/bot/rules/test_burst_shared.py +++ b/tests/bot/rules/test_burst_shared.py @@ -5,7 +5,8 @@ from tests.helpers import MockMessage, async_test def make_msg(author: str) -> MockMessage: - """Init a MockMessage instance with the passed arg. + """ + Init a MockMessage instance with the passed arg. This serves as a shorthand / alias to keep the test cases visually clean. """ @@ -20,7 +21,8 @@ class BurstSharedRuleTests(unittest.TestCase): @async_test async def test_allows_messages_within_limit(self): - """Cases that do not violate the rule. + """ + Cases that do not violate the rule. There really isn't more to test here than a single case. """ -- cgit v1.2.3 From 93d19f378e206135286e376377e7fc8f5bdcc7a2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Feb 2020 18:25:07 +0100 Subject: Implement RuleTest ABC This will serve as an ABC for tests for individual rules. The base class provides runners for allowed and disallowed cases, and the children classes then only provide the cases and implementations of helper methods specific to each rule. --- tests/bot/rules/__init__.py | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) (limited to 'tests') diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py index e69de29bb..d7cd7b66b 100644 --- a/tests/bot/rules/__init__.py +++ b/tests/bot/rules/__init__.py @@ -0,0 +1,76 @@ +import unittest +from abc import abstractmethod +from typing import Callable, Dict, Iterable, List, NamedTuple, Tuple + +from tests.helpers import MockMessage + + +class DisallowedCase(NamedTuple): + """Encapsulation for test cases expected to fail.""" + recent_messages: List[MockMessage] + culprits: Iterable[str] + n_violations: int + + +class RuleTest(unittest.TestCase): + """ + Abstract class for antispam rule test cases. + + Tests for specific rules should inherit from `RuleTest` and implement + `relevant_messages` and `get_report`. Each instance should also set the + `apply` and `config` attributes as necessary. + + The execution of test cases can then be delegated to the `run_allowed` + and `run_disallowed` methods. + """ + + apply: Callable # The tested rule's apply function + config: Dict[str, int] + + async def run_allowed(self, cases: Tuple[List[MockMessage], ...]) -> None: + """Run all `cases` against `self.apply` expecting them to pass.""" + 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 self.apply(last_message, recent_messages, self.config) + ) + + async def run_disallowed(self, cases: Tuple[DisallowedCase, ...]) -> None: + """Run all `cases` against `self.apply` expecting them to fail.""" + for case in cases: + recent_messages, culprits, n_violations = case + last_message = recent_messages[0] + relevant_messages = self.relevant_messages(case) + desired_output = ( + self.get_report(case), + culprits, + relevant_messages, + ) + + with self.subTest( + last_message=last_message, + recent_messages=recent_messages, + relevant_messages=relevant_messages, + n_violations=n_violations, + config=self.config, + ): + self.assertTupleEqual( + await self.apply(last_message, recent_messages, self.config), + desired_output, + ) + + @abstractmethod + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + """Give expected relevant messages for `case`.""" + raise NotImplementedError + + @abstractmethod + def get_report(self, case: DisallowedCase) -> str: + """Give expected error report for `case`.""" + raise NotImplementedError -- cgit v1.2.3 From b89f9c55329aa44448d55963e22ce4f7a6ec0ff6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Feb 2020 18:31:50 +0100 Subject: Adjust existing tests to inherit from RuleTest ABC --- tests/bot/rules/test_attachments.py | 79 +++++++++++----------------------- tests/bot/rules/test_burst.py | 44 +++++++------------ tests/bot/rules/test_burst_shared.py | 40 +++++++---------- tests/bot/rules/test_chars.py | 53 ++++++++++------------- tests/bot/rules/test_discord_emojis.py | 44 +++++++------------ tests/bot/rules/test_links.py | 66 ++++++++-------------------- tests/bot/rules/test_mentions.py | 76 +++++++++++--------------------- tests/bot/rules/test_role_mentions.py | 49 +++++++++------------ 8 files changed, 157 insertions(+), 294 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index 419336417..e54b4b5b8 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -1,25 +1,20 @@ -import unittest -from typing import List, NamedTuple, Tuple +from typing import Iterable from bot.rules import attachments +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test -class Case(NamedTuple): - recent_messages: List[MockMessage] - culprit: Tuple[str] - total_attachments: int - - def make_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): +class AttachmentRuleTests(RuleTest): """Tests applying the `attachments` antispam rule.""" def setUp(self): + self.apply = attachments.apply self.config = {"max": 5, "interval": 10} @async_test @@ -31,68 +26,46 @@ class AttachmentRuleTests(unittest.TestCase): [make_msg("bob", 2), make_msg("alice", 2), make_msg("bob", 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 attachments.apply(last_message, recent_messages, self.config) - ) + await self.run_allowed(cases) @async_test async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( - Case( + DisallowedCase( [make_msg("bob", 4), make_msg("bob", 0), make_msg("bob", 6)], ("bob",), - 10 + 10, ), - Case( + DisallowedCase( [make_msg("bob", 4), make_msg("alice", 6), make_msg("bob", 2)], ("bob",), - 6 + 6, ), - Case( + DisallowedCase( [make_msg("alice", 6)], ("alice",), - 6 + 6, ), - ( + DisallowedCase( [make_msg("alice", 1) for _ in range(6)], ("alice",), - 6 + 6, ), ) - 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 - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.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['interval']}s", - culprit, - relevant_messages - ) - self.assertTupleEqual( - await attachments.apply(last_message, recent_messages, self.config), - desired_output - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} attachments in {self.config['interval']}s" diff --git a/tests/bot/rules/test_burst.py b/tests/bot/rules/test_burst.py index afcc5554d..72f0be0c7 100644 --- a/tests/bot/rules/test_burst.py +++ b/tests/bot/rules/test_burst.py @@ -1,6 +1,7 @@ -import unittest +from typing import Iterable from bot.rules import burst +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test @@ -13,10 +14,11 @@ def make_msg(author: str) -> MockMessage: return MockMessage(author=author) -class BurstRuleTests(unittest.TestCase): +class BurstRuleTests(RuleTest): """Tests the `burst` antispam rule.""" def setUp(self): + self.apply = burst.apply self.config = {"max": 2, "interval": 10} @async_test @@ -27,44 +29,28 @@ class BurstRuleTests(unittest.TestCase): [make_msg("bob"), make_msg("alice"), make_msg("bob")], ) - 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 burst.apply(last_message, recent_messages, self.config)) + await self.run_allowed(cases) @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the amount of messages exceeds the limit, triggering the rule.""" cases = ( - ( + DisallowedCase( [make_msg("bob"), make_msg("bob"), make_msg("bob")], - "bob", + ("bob",), 3, ), - ( + DisallowedCase( [make_msg("bob"), make_msg("bob"), make_msg("alice"), make_msg("bob")], - "bob", + ("bob",), 3, ), ) - for recent_messages, culprit, total_msgs in cases: - last_message = recent_messages[0] - relevant_messages = tuple(msg for msg in recent_messages if msg.author == culprit) - expected_output = ( - f"sent {total_msgs} messages in {self.config['interval']}s", - (culprit,), - relevant_messages, - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + return tuple(msg for msg in case.recent_messages if msg.author in case.culprits) - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - config=self.config, - expected_output=expected_output, - ): - self.assertTupleEqual( - await burst.apply(last_message, recent_messages, self.config), - expected_output, - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} messages in {self.config['interval']}s" diff --git a/tests/bot/rules/test_burst_shared.py b/tests/bot/rules/test_burst_shared.py index 401e0b666..47367a5f8 100644 --- a/tests/bot/rules/test_burst_shared.py +++ b/tests/bot/rules/test_burst_shared.py @@ -1,6 +1,7 @@ -import unittest +from typing import Iterable from bot.rules import burst_shared +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test @@ -13,10 +14,11 @@ def make_msg(author: str) -> MockMessage: return MockMessage(author=author) -class BurstSharedRuleTests(unittest.TestCase): +class BurstSharedRuleTests(RuleTest): """Tests the `burst_shared` antispam rule.""" def setUp(self): + self.apply = burst_shared.apply self.config = {"max": 2, "interval": 10} @async_test @@ -26,42 +28,32 @@ class BurstSharedRuleTests(unittest.TestCase): There really isn't more to test here than a single case. """ - recent_messages = [make_msg("spongebob"), make_msg("patrick")] - last_message = recent_messages[0] + cases = ( + [make_msg("spongebob"), make_msg("patrick")], + ) - self.assertIsNone(await burst_shared.apply(last_message, recent_messages, self.config)) + await self.run_allowed(cases) @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the amount of messages exceeds the limit, triggering the rule.""" cases = ( - ( + DisallowedCase( [make_msg("bob"), make_msg("bob"), make_msg("bob")], {"bob"}, 3, ), - ( + DisallowedCase( [make_msg("bob"), make_msg("bob"), make_msg("alice"), make_msg("bob")], {"bob", "alice"}, 4, ), ) - for recent_messages, culprits, total_msgs in cases: - last_message = recent_messages[0] - expected_output = ( - f"sent {total_msgs} messages in {self.config['interval']}s", - culprits, - recent_messages, - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + return case.recent_messages - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - config=self.config, - expected_output=expected_output, - ): - self.assertTupleEqual( - await burst_shared.apply(last_message, recent_messages, self.config), - expected_output, - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} messages in {self.config['interval']}s" diff --git a/tests/bot/rules/test_chars.py b/tests/bot/rules/test_chars.py index f466a898e..7cc36f49e 100644 --- a/tests/bot/rules/test_chars.py +++ b/tests/bot/rules/test_chars.py @@ -1,6 +1,7 @@ -import unittest +from typing import Iterable from bot.rules import chars +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test @@ -9,10 +10,11 @@ def make_msg(author: str, n_chars: int) -> MockMessage: return MockMessage(author=author, content="A" * n_chars) -class CharsRuleTests(unittest.TestCase): +class CharsRuleTests(RuleTest): """Tests the `chars` antispam rule.""" def setUp(self): + self.apply = chars.apply self.config = { "max": 20, # Max allowed sum of chars per user "interval": 10, @@ -27,49 +29,38 @@ class CharsRuleTests(unittest.TestCase): [make_msg("bob", 15), make_msg("alice", 15)], ) - 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 chars.apply(last_message, recent_messages, self.config)) + await self.run_allowed(cases) @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the total amount of chars exceeds the limit, triggering the rule.""" cases = ( - ( + DisallowedCase( [make_msg("bob", 21)], - "bob", + ("bob",), 21, ), - ( + DisallowedCase( [make_msg("bob", 15), make_msg("bob", 15)], - "bob", + ("bob",), 30, ), - ( + DisallowedCase( [make_msg("alice", 15), make_msg("bob", 20), make_msg("alice", 15)], - "alice", + ("alice",), 30, ), ) - for recent_messages, culprit, total_chars in cases: - last_message = recent_messages[0] - relevant_messages = tuple(msg for msg in recent_messages if msg.author == culprit) - expected_output = ( - f"sent {total_chars} characters in {self.config['interval']}s", - (culprit,), - relevant_messages, - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.recent_messages + if msg.author == last_message.author + ) - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - config=self.config, - expected_output=expected_output, - ): - self.assertTupleEqual( - await chars.apply(last_message, recent_messages, self.config), - expected_output, - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} characters in {self.config['interval']}s" diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py index 1c56c9563..0239b0b00 100644 --- a/tests/bot/rules/test_discord_emojis.py +++ b/tests/bot/rules/test_discord_emojis.py @@ -1,6 +1,7 @@ -import unittest +from typing import Iterable from bot.rules import discord_emojis +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test discord_emoji = "<:abcd:1234>" # Discord emojis follow the format <:name:id> @@ -11,10 +12,11 @@ def make_msg(author: str, n_emojis: int) -> MockMessage: return MockMessage(author=author, content=discord_emoji * n_emojis) -class DiscordEmojisRuleTests(unittest.TestCase): +class DiscordEmojisRuleTests(RuleTest): """Tests for the `discord_emojis` antispam rule.""" def setUp(self): + self.apply = discord_emojis.apply self.config = {"max": 2, "interval": 10} @async_test @@ -25,44 +27,28 @@ class DiscordEmojisRuleTests(unittest.TestCase): [make_msg("alice", 1), make_msg("bob", 2), make_msg("alice", 1)], ) - 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 discord_emojis.apply(last_message, recent_messages, self.config)) + await self.run_allowed(cases) @async_test async def test_disallows_messages_beyond_limit(self): """Cases with more than the allowed amount of discord emojis.""" cases = ( - ( + DisallowedCase( [make_msg("bob", 3)], - "bob", + ("bob",), 3, ), - ( + DisallowedCase( [make_msg("alice", 2), make_msg("bob", 2), make_msg("alice", 2)], - "alice", + ("alice",), 4, ), ) - for recent_messages, culprit, total_emojis in cases: - last_message = recent_messages[0] - relevant_messages = tuple(msg for msg in recent_messages if msg.author == culprit) - expected_output = ( - f"sent {total_emojis} emojis in {self.config['interval']}s", - (culprit,), - relevant_messages, - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + return tuple(msg for msg in case.recent_messages if msg.author in case.culprits) - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - config=self.config, - expected_output=expected_output, - ): - self.assertTupleEqual( - await discord_emojis.apply(last_message, recent_messages, self.config), - expected_output, - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} emojis in {self.config['interval']}s" diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index b77e01c84..3c3f90e5f 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -1,26 +1,21 @@ -import unittest -from typing import List, NamedTuple, Tuple +from typing import Iterable from bot.rules import links +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test -class Case(NamedTuple): - recent_messages: List[MockMessage] - culprit: Tuple[str] - total_links: int - - def make_msg(author: str, total_links: int) -> MockMessage: """Makes a message with `total_links` links.""" content = " ".join(["https://pydis.com"] * total_links) return MockMessage(author=author, content=content) -class LinksTests(unittest.TestCase): +class LinksTests(RuleTest): """Tests applying the `links` rule.""" def setUp(self): + self.apply = links.apply self.config = { "max": 2, "interval": 10 @@ -37,61 +32,38 @@ class LinksTests(unittest.TestCase): [make_msg("bob", 2), make_msg("alice", 2)] # Only messages from latest author count ) - 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 links.apply(last_message, recent_messages, self.config) - ) + await self.run_allowed(cases) @async_test async def test_links_exceeding_limit(self): """Messages with a a higher than allowed amount of links.""" cases = ( - Case( + DisallowedCase( [make_msg("bob", 1), make_msg("bob", 2)], ("bob",), 3 ), - Case( + DisallowedCase( [make_msg("alice", 1), make_msg("alice", 1), make_msg("alice", 1)], ("alice",), 3 ), - Case( + DisallowedCase( [make_msg("alice", 2), make_msg("bob", 3), make_msg("alice", 1)], ("alice",), 3 ) ) - 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 - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.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_links=total_links, - config=self.config - ): - desired_output = ( - f"sent {total_links} links in {self.config['interval']}s", - culprit, - relevant_messages - ) - self.assertTupleEqual( - await links.apply(last_message, recent_messages, self.config), - desired_output - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} links in {self.config['interval']}s" diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 43211f097..ebcdabac6 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -1,28 +1,23 @@ -import unittest -from typing import List, NamedTuple, Tuple +from typing import Iterable from bot.rules import mentions +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test -class Case(NamedTuple): - recent_messages: List[MockMessage] - culprit: Tuple[str] - total_mentions: int - - def make_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): +class TestMentions(RuleTest): """Tests applying the `mentions` antispam rule.""" def setUp(self): + self.apply = mentions.apply self.config = { "max": 2, - "interval": 10 + "interval": 10, } @async_test @@ -32,64 +27,41 @@ class TestMentions(unittest.TestCase): [make_msg("bob", 0)], [make_msg("bob", 2)], [make_msg("bob", 1), make_msg("bob", 1)], - [make_msg("bob", 1), make_msg("alice", 2)] + [make_msg("bob", 1), make_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) - ) + await self.run_allowed(cases) @async_test async def test_mentions_exceeding_limit(self): """Messages with a higher than allowed amount of mentions.""" cases = ( - Case( + DisallowedCase( [make_msg("bob", 3)], ("bob",), - 3 + 3, ), - Case( + DisallowedCase( [make_msg("alice", 2), make_msg("alice", 0), make_msg("alice", 1)], ("alice",), - 3 + 3, ), - Case( + DisallowedCase( [make_msg("bob", 2), make_msg("alice", 3), make_msg("bob", 2)], ("bob",), - 4 + 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 - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.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 - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} mentions in {self.config['interval']}s" diff --git a/tests/bot/rules/test_role_mentions.py b/tests/bot/rules/test_role_mentions.py index 6377ffbc8..b339cccf7 100644 --- a/tests/bot/rules/test_role_mentions.py +++ b/tests/bot/rules/test_role_mentions.py @@ -1,6 +1,7 @@ -import unittest +from typing import Iterable from bot.rules import role_mentions +from tests.bot.rules import DisallowedCase, RuleTest from tests.helpers import MockMessage, async_test @@ -9,10 +10,11 @@ def make_msg(author: str, n_mentions: int) -> MockMessage: return MockMessage(author=author, role_mentions=[None] * n_mentions) -class RoleMentionsRuleTests(unittest.TestCase): +class RoleMentionsRuleTests(RuleTest): """Tests for the `role_mentions` antispam rule.""" def setUp(self): + self.apply = role_mentions.apply self.config = {"max": 2, "interval": 10} @async_test @@ -23,44 +25,33 @@ class RoleMentionsRuleTests(unittest.TestCase): [make_msg("bob", 1), make_msg("alice", 1), make_msg("bob", 1)], ) - 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 role_mentions.apply(last_message, recent_messages, self.config)) + await self.run_allowed(cases) @async_test async def test_disallows_messages_beyond_limit(self): """Cases with more than the allowed amount of role mentions.""" cases = ( - ( + DisallowedCase( [make_msg("bob", 3)], - "bob", + ("bob",), 3, ), - ( + DisallowedCase( [make_msg("alice", 2), make_msg("bob", 2), make_msg("alice", 2)], - "alice", + ("alice",), 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 == culprit) - expected_output = ( - f"sent {total_mentions} role mentions in {self.config['interval']}s", - (culprit,), - relevant_messages, - ) + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.recent_messages + if msg.author == last_message.author + ) - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - config=self.config, - expected_output=expected_output, - ): - self.assertTupleEqual( - await role_mentions.apply(last_message, recent_messages, self.config), - expected_output, - ) + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} role mentions in {self.config['interval']}s" -- cgit v1.2.3 From 9758e32beba0f88cdb1ee80c2e4bc5539013740e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Feb 2020 18:36:36 +0100 Subject: Make RuleTest use ABCMeta This will prevent child classes to be instantiated unless they implement all abstract methods, leading to a more descriptive error message. --- tests/bot/rules/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py index d7cd7b66b..36c986fe1 100644 --- a/tests/bot/rules/__init__.py +++ b/tests/bot/rules/__init__.py @@ -1,5 +1,5 @@ import unittest -from abc import abstractmethod +from abc import ABCMeta, abstractmethod from typing import Callable, Dict, Iterable, List, NamedTuple, Tuple from tests.helpers import MockMessage @@ -12,7 +12,7 @@ class DisallowedCase(NamedTuple): n_violations: int -class RuleTest(unittest.TestCase): +class RuleTest(unittest.TestCase, metaclass=ABCMeta): """ Abstract class for antispam rule test cases. -- cgit v1.2.3 From 54e8c98f6ceb4572df5c73719649624613adeda6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Feb 2020 21:49:46 +0100 Subject: Add unit test for duplicates antispam rule --- tests/bot/rules/test_duplicates.py | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/bot/rules/test_duplicates.py (limited to 'tests') diff --git a/tests/bot/rules/test_duplicates.py b/tests/bot/rules/test_duplicates.py new file mode 100644 index 000000000..59e0fb6ef --- /dev/null +++ b/tests/bot/rules/test_duplicates.py @@ -0,0 +1,66 @@ +from typing import Iterable + +from bot.rules import duplicates +from tests.bot.rules import DisallowedCase, RuleTest +from tests.helpers import MockMessage, async_test + + +def make_msg(author: str, content: str) -> MockMessage: + """Give a MockMessage instance with `author` and `content` attrs.""" + return MockMessage(author=author, content=content) + + +class DuplicatesRuleTests(RuleTest): + """Tests the `duplicates` antispam rule.""" + + def setUp(self): + self.apply = duplicates.apply + self.config = {"max": 2, "interval": 10} + + @async_test + async def test_allows_messages_within_limit(self): + """Cases which do not violate the rule.""" + cases = ( + [make_msg("alice", "A"), make_msg("alice", "A")], + [make_msg("alice", "A"), make_msg("alice", "B"), make_msg("alice", "C")], # Non-duplicate + [make_msg("alice", "A"), make_msg("bob", "A"), make_msg("alice", "A")], # Different author + ) + + await self.run_allowed(cases) + + @async_test + async def test_disallows_messages_beyond_limit(self): + """Cases with too many duplicate messages from the same author.""" + cases = ( + DisallowedCase( + [make_msg("alice", "A"), make_msg("alice", "A"), make_msg("alice", "A")], + ("alice",), + 3, + ), + DisallowedCase( + [make_msg("bob", "A"), make_msg("alice", "A"), make_msg("bob", "A"), make_msg("bob", "A")], + ("bob",), + 3, # 4 duplicate messages, but only 3 from bob + ), + DisallowedCase( + [make_msg("bob", "A"), make_msg("bob", "B"), make_msg("bob", "A"), make_msg("bob", "A")], + ("bob",), + 3, # 4 message from bob, but only 3 duplicates + ), + ) + + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_message = case.recent_messages[0] + return tuple( + msg + for msg in case.recent_messages + if ( + msg.author == last_message.author + and msg.content == last_message.content + ) + ) + + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} duplicated messages in {self.config['interval']}s" -- cgit v1.2.3 From cae7f25a735516d92af34353715ed23604b1d71d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Feb 2020 21:51:25 +0100 Subject: Add unit test for newlines antispam rule --- tests/bot/rules/test_newlines.py | 105 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/bot/rules/test_newlines.py (limited to 'tests') diff --git a/tests/bot/rules/test_newlines.py b/tests/bot/rules/test_newlines.py new file mode 100644 index 000000000..d61c4609d --- /dev/null +++ b/tests/bot/rules/test_newlines.py @@ -0,0 +1,105 @@ +from typing import Iterable, List + +from bot.rules import newlines +from tests.bot.rules import DisallowedCase, RuleTest +from tests.helpers import MockMessage, async_test + + +def make_msg(author: str, newline_groups: List[int]) -> MockMessage: + """Init a MockMessage instance with `author` and content configured by `newline_groups". + + Configure content by passing a list of ints, where each int `n` will generate + a separate group of `n` newlines. + + Example: + newline_groups=[3, 1, 2] -> content="\n\n\n \n \n\n" + """ + content = " ".join("\n" * n for n in newline_groups) + return MockMessage(author=author, content=content) + + +class TotalNewlinesRuleTests(RuleTest): + """Tests the `newlines` antispam rule against allowed cases and total newline count violations.""" + + def setUp(self): + self.apply = newlines.apply + self.config = { + "max": 5, # Max sum of newlines in relevant messages + "max_consecutive": 3, # Max newlines in one group, in one message + "interval": 10, + } + + @async_test + async def test_allows_messages_within_limit(self): + """Cases which do not violate the rule.""" + cases = ( + [make_msg("alice", [])], # Single message with no newlines + [make_msg("alice", [1, 2]), make_msg("alice", [1, 1])], # 5 newlines in 2 messages + [make_msg("alice", [2, 2, 1]), make_msg("bob", [2, 3])], # 5 newlines from each author + [make_msg("bob", [1]), make_msg("alice", [5])], # Alice breaks the rule, but only bob is relevant + ) + + await self.run_allowed(cases) + + @async_test + async def test_disallows_messages_total(self): + """Cases which violate the rule by having too many newlines in total.""" + cases = ( + DisallowedCase( # Alice sends a total of 6 newlines (disallowed) + [make_msg("alice", [2, 2]), make_msg("alice", [2])], + ("alice",), + 6, + ), + DisallowedCase( # Here we test that only alice's newlines count in the sum + [make_msg("alice", [2, 2]), make_msg("bob", [3]), make_msg("alice", [3])], + ("alice",), + 7, + ), + ) + + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_author = case.recent_messages[0].author + return tuple(msg for msg in case.recent_messages if msg.author == last_author) + + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} newlines in {self.config['interval']}s" + + +class GroupNewlinesRuleTests(RuleTest): + """ + Tests the `newlines` antispam rule against max consecutive newline violations. + + As these violations yield a different error report, they require a different + `get_report` implementation. + """ + + def setUp(self): + self.apply = newlines.apply + self.config = {"max": 5, "max_consecutive": 3, "interval": 10} + + @async_test + async def test_disallows_messages_consecutive(self): + """Cases which violate the rule due to having too many consecutive newlines.""" + cases = ( + DisallowedCase( # Bob sends a group of newlines too large + [make_msg("bob", [4])], + ("bob",), + 4, + ), + DisallowedCase( # Alice sends 5 in total (allowed), but 4 in one group (disallowed) + [make_msg("alice", [1]), make_msg("alice", [4])], + ("alice",), + 4, + ), + ) + + await self.run_disallowed(cases) + + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: + last_author = case.recent_messages[0].author + return tuple(msg for msg in case.recent_messages if msg.author == last_author) + + def get_report(self, case: DisallowedCase) -> str: + return f"sent {case.n_violations} consecutive newlines in {self.config['interval']}s" -- cgit v1.2.3 From 79fb50772065e23827396911682da51e24440787 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:48:39 +0100 Subject: Update tests to reflect status changes --- tests/bot/cogs/test_information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 4496a2ae0..519d2622b 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -125,10 +125,10 @@ class InformationCogTests(unittest.TestCase): ) ], members=[ - *(helpers.MockMember(status='online') for _ in range(2)), - *(helpers.MockMember(status='idle') for _ in range(1)), - *(helpers.MockMember(status='dnd') for _ in range(4)), - *(helpers.MockMember(status='offline') for _ in range(3)), + *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), + *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), + *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), + *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), ], member_count=1_234, icon_url='a-lemon.jpg', -- cgit v1.2.3 From 24136095302d192b25d83430a1b9607f05f6059c Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 22:37:03 +0100 Subject: Fix some of the testing for information.py; I think this should be it. (hopefully). --- tests/bot/cogs/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 519d2622b..296c3c556 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -153,8 +153,8 @@ class InformationCogTests(unittest.TestCase): **Counts** Members: {self.ctx.guild.member_count:,} Roles: {len(self.ctx.guild.roles)} - Text: 1 - Voice: 1 + Text Channels: 1 + Voice Channels: 1 Channel categories: 1 **Members** -- cgit v1.2.3 From adf63e65a0b861cb02a6e8cc1e5b2c2c09e57726 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:05:09 +0100 Subject: Create an AsyncContextManagerMock mock for testing asynchronous context managers It can be used to test aiohttp request functions, since they are async context managers --- tests/helpers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..6aee8623f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -127,6 +127,18 @@ class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): return super().__call__(*args, **kwargs) +class AsyncContextManagerMock(unittest.mock.MagicMock): + def __init__(self, return_value: Any): + super().__init__() + self._return_value = return_value + + async def __aenter__(self): + return self._return_value + + async def __aexit__(self, *args): + pass + + class AsyncIteratorMock: """ A class to mock asynchronous iterators. -- cgit v1.2.3 From 8c8f8dd50f376c3398c04e7bbe76b5028b69ff83 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sun, 9 Feb 2020 17:11:47 +0100 Subject: Write tests for bot/cogs/test_snekbox.py --- tests/bot/cogs/test_snekbox.py | 363 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 tests/bot/cogs/test_snekbox.py (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py new file mode 100644 index 000000000..293efed0f --- /dev/null +++ b/tests/bot/cogs/test_snekbox.py @@ -0,0 +1,363 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock, Mock, call, patch + +from bot.cogs import snekbox +from bot.cogs.snekbox import Snekbox +from bot.constants import URLs +from tests.helpers import ( + AsyncContextManagerMock, AsyncMock, MockBot, MockContext, MockMessage, MockReaction, MockUser, async_test +) + + +class SnekboxTests(unittest.TestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + + self.mocked_post = MagicMock() + self.mocked_post.json = AsyncMock() + self.bot.http_session.post = MagicMock(return_value=AsyncContextManagerMock(self.mocked_post)) + + self.cog = Snekbox(bot=self.bot) + + @async_test + async def test_post_eval(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + await self.cog.post_eval("import random") + self.bot.http_session.post.assert_called_once_with( + URLs.snekbox_eval_api, + json={"input": "import random"}, + raise_for_status=True + ) + + @async_test + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LEN.""" + self.assertEqual(await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)), "too long to upload") + + @async_test + async def test_upload_output(self): + """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" + key = "RainbowDash" + self.mocked_post.json.return_value = {"key": key} + + self.assertEqual( + await self.cog.upload_output("My awesome output"), + URLs.paste_service.format(key=key) + ) + self.bot.http_session.post.assert_called_once_with( + URLs.paste_service.format(key="documents"), + data="My awesome output", + raise_for_status=True + ) + + @async_test + async def test_upload_output_gracefully_fallback_if_exception_during_request(self): + """Output upload gracefully fallback if the upload fail.""" + self.mocked_post.json.side_effect = Exception + log = logging.getLogger("bot.cogs.snekbox") + with self.assertLogs(logger=log, level='ERROR'): + await self.cog.upload_output('My awesome output!') + + @async_test + async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): + """Output upload gracefully fallback if there is no key entry in the response body.""" + self.mocked_post.json.return_value = {} + self.assertEqual((await self.cog.upload_output('My awesome output!')), None) + + def test_prepare_input(self): + cases = ( + ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), + ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), + ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), + ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ) + for case, expected, testname in cases: + with self.subTest(msg=f'Extract code from {testname}.', case=case, expected=expected): + self.assertEqual(self.cog.prepare_input(case), expected) + + def test_get_results_message(self): + """Return error and message according to the eval result.""" + cases = ( + ('ERROR', None, ('Your eval job has failed', 'ERROR')), + ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), + ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + self.assertEqual(self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}), expected) + + @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) + def test_get_results_message_invalid_signal(self, mock_Signals: Mock): + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127', '') + ) + + @patch('bot.cogs.snekbox.Signals') + def test_get_results_message_valid_signal(self, mock_Signals: Mock): + mock_Signals.return_value.name = 'SIGTEST' + self.assertEqual( + self.cog.get_results_message({'stdout': '', 'returncode': 127}), + ('Your eval job has completed with return code 127 (SIGTEST)', '') + ) + + def test_get_status_emoji(self): + """Return emoji according to the eval result.""" + cases = ( + ('', -1, ':warning:'), + ('Hello world!', 0, ':white_check_mark:'), + ('Invalid beard size', -1, ':x:') + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + self.assertEqual(self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}), expected) + + @async_test + async def test_format_output(self): + """Test output formatting.""" + self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') + + too_many_lines = ( + '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' + '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' + ) + too_long_too_many_lines = ( + "\n".join( + f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) + )[:1000] + "\n... (truncated - too long, too many lines)" + ) + + cases = ( + ('', ('[No output]', None), 'No output'), + ('My awesome output', ('My awesome output', None), 'One line output'), + ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), + (' Date: Tue, 31 Dec 2019 12:19:44 -0800 Subject: Tests: add a MockAPIClient --- tests/helpers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..71b80a223 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -12,6 +12,7 @@ from typing import Any, Iterable, Optional import discord from discord.ext.commands import Context +from bot.api import APIClient from bot.bot import Bot @@ -324,6 +325,22 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): self.mention = f"@{self.name}" +# Create an APIClient instance to get a realistic MagicMock of `bot.api.APIClient` +api_client_instance = APIClient(loop=unittest.mock.MagicMock()) + + +class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock APIClient objects. + + Instances of this class will follow the specifications of `bot.api.APIClient` instances. + For more information, see the `MockGuild` docstring. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=api_client_instance, **kwargs) + + # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) bot_instance.http_session = None @@ -340,6 +357,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec_set=bot_instance, **kwargs) + self.api_client = MockAPIClient() # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and # and should therefore be awaited. (The documentation calls it a coroutine as well, which -- cgit v1.2.3 From 43f25fcbbb6cf7b9960317955b57f5e171675d85 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 16:45:57 -0800 Subject: Sync tests: rename the role syncer test case --- tests/bot/cogs/sync/test_roles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 27ae27639..450a192b7 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ import unittest from bot.cogs.sync.syncers import Role, get_roles_for_sync -class GetRolesForSyncTests(unittest.TestCase): +class RoleSyncerTests(unittest.TestCase): """Tests constructing the roles to synchronize with the site.""" def test_get_roles_for_sync_empty_return_for_equal_roles(self): -- cgit v1.2.3 From c487d80c163682ad8e079257b6bf4bfd11743629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 16:05:14 -0800 Subject: Sync tests: add fixture to create a guild with roles --- tests/bot/cogs/sync/test_roles.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 450a192b7..5ae475b2a 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,11 +1,31 @@ import unittest -from bot.cogs.sync.syncers import Role, get_roles_for_sync +import discord + +from bot.cogs.sync.syncers import RoleSyncer +from tests import helpers class RoleSyncerTests(unittest.TestCase): """Tests constructing the roles to synchronize with the site.""" + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) + + @staticmethod + def get_guild(*roles): + """Fixture to return a guild object with the given roles.""" + guild = helpers.MockGuild() + guild.roles = [] + + for role in roles: + role.colour = discord.Colour(role.colour) + role.permissions = discord.Permissions(role.permissions) + guild.roles.append(helpers.MockRole(**role)) + + return guild + def test_get_roles_for_sync_empty_return_for_equal_roles(self): """No roles should be synced when no diff is found.""" api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} -- cgit v1.2.3 From 28c7ce0465bafc0e07432a94d6f388938a2b3b4d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 16:52:45 -0800 Subject: Sync tests: fix creation of MockRoles Role was being accessed like a class when it is actually a dict. --- tests/bot/cogs/sync/test_roles.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 5ae475b2a..b1fe500cd 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -20,9 +20,10 @@ class RoleSyncerTests(unittest.TestCase): guild.roles = [] for role in roles: - role.colour = discord.Colour(role.colour) - role.permissions = discord.Permissions(role.permissions) - guild.roles.append(helpers.MockRole(**role)) + mock_role = helpers.MockRole(**role) + mock_role.colour = discord.Colour(role["colour"]) + mock_role.permissions = discord.Permissions(role["permissions"]) + guild.roles.append(mock_role) return guild -- cgit v1.2.3 From 384a27d18ba258477239daa37569397092e26d76 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 11:44:06 -0800 Subject: Sync tests: test empty diff for identical roles --- tests/bot/cogs/sync/test_roles.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index b1fe500cd..2a60e1fe2 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,3 +1,4 @@ +import asyncio import unittest import discord @@ -27,15 +28,17 @@ class RoleSyncerTests(unittest.TestCase): return guild - def test_get_roles_for_sync_empty_return_for_equal_roles(self): - """No roles should be synced when no diff is found.""" - 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)} + def test_empty_diff_for_identical_roles(self): + """No differences should be found if the roles in the guild and DB are identical.""" + role = {"id": 41, "name": "name", "colour": 33, "permissions": 0x8, "position": 1} - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), set(), set()) - ) + self.bot.api_client.get.return_value = [role] + guild = self.get_guild(role) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), set()) + + self.assertEqual(actual_diff, expected_diff) def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): """Roles to be synced are returned when non-ID attributes differ.""" -- cgit v1.2.3 From 3bafbde6eddbecf3a987b4fe40da00ec79ce4bd4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 16:47:17 -0800 Subject: Sync tests: test diff for updated roles --- tests/bot/cogs/sync/test_roles.py | 43 +++++++++++++++------------------------ 1 file changed, 16 insertions(+), 27 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 2a60e1fe2..31bf13933 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ import unittest import discord -from bot.cogs.sync.syncers import RoleSyncer +from bot.cogs.sync.syncers import RoleSyncer, _Role from tests import helpers @@ -40,35 +40,24 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): - """Roles to be synced are returned when non-ID attributes differ.""" - 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)} + def test_diff_for_updated_roles(self): + """Only updated roles should be added to the updated set of the diff.""" + db_roles = [ + {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, + {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + ] + guild_roles = [ + {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, + {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + ] - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), guild_roles, set()) - ) + self.bot.api_client.get.return_value = db_roles + guild = self.get_guild(*guild_roles) - def test_get_roles_only_returns_roles_that_require_update(self): - """Roles that require an update should be returned as the second tuple element.""" - 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) - } + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), {_Role(**guild_roles[0])}, set()) - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - ) + self.assertEqual(actual_diff, expected_diff) def test_get_roles_returns_new_roles_in_first_tuple_element(self): """Newly created roles are returned as the first tuple element.""" -- cgit v1.2.3 From d9f6fc4c089814992f8c049cb2837e798390ea7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 17:14:25 -0800 Subject: Sync tests: create a role in setUp to use as a constant --- tests/bot/cogs/sync/test_roles.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 31bf13933..4eadf8f34 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -13,6 +13,7 @@ class RoleSyncerTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) + self.constant_role = {"id": 9, "name": "test", "colour": 7, "permissions": 0, "position": 3} @staticmethod def get_guild(*roles): @@ -30,10 +31,8 @@ class RoleSyncerTests(unittest.TestCase): def test_empty_diff_for_identical_roles(self): """No differences should be found if the roles in the guild and DB are identical.""" - role = {"id": 41, "name": "name", "colour": 33, "permissions": 0x8, "position": 1} - - self.bot.api_client.get.return_value = [role] - guild = self.get_guild(role) + self.bot.api_client.get.return_value = [self.constant_role] + guild = self.get_guild(self.constant_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), set(), set()) @@ -44,11 +43,11 @@ class RoleSyncerTests(unittest.TestCase): """Only updated roles should be added to the updated set of the diff.""" db_roles = [ {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, - {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + self.constant_role, ] guild_roles = [ {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, - {"id": 53, "name": "other", "colour": 55, "permissions": 0, "position": 3}, + self.constant_role, ] self.bot.api_client.get.return_value = db_roles -- cgit v1.2.3 From 99ff41a7abe6b1ccba809654657ba0ba25c43008 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 1 Jan 2020 17:25:02 -0800 Subject: Sync tests: test diff for new roles --- tests/bot/cogs/sync/test_roles.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 4eadf8f34..184050618 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -40,8 +40,8 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) def test_diff_for_updated_roles(self): - """Only updated roles should be added to the updated set of the diff.""" - db_roles = [ + """Only updated roles should be added to the 'updated' set of the diff.""" + self.bot.api_client.get.return_value = [ {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, self.constant_role, ] @@ -50,7 +50,6 @@ class RoleSyncerTests(unittest.TestCase): self.constant_role, ] - self.bot.api_client.get.return_value = db_roles guild = self.get_guild(*guild_roles) actual_diff = asyncio.run(self.syncer._get_diff(guild)) @@ -58,24 +57,20 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_returns_new_roles_in_first_tuple_element(self): - """Newly created roles are returned as the 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) - } + def test_diff_for_new_roles(self): + """Only new roles should be added to the 'created' set of the diff.""" + self.bot.api_client.get.return_value = [self.constant_role] + guild_roles = [ + self.constant_role, + {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + ] - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - ) + guild = self.get_guild(*guild_roles) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_Role(**guild_roles[1])}, set(), set()) + + self.assertEqual(actual_diff, expected_diff) def test_get_roles_returns_roles_to_update_and_new_roles(self): """Newly created and updated roles should be returned together.""" -- cgit v1.2.3 From 51d0e8672a4836b46d99a7a5af42a3d9f363cf57 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:17:43 -0800 Subject: Sync tests: test diff for deleted roles --- tests/bot/cogs/sync/test_roles.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 184050618..694ee6276 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -91,24 +91,17 @@ class RoleSyncerTests(unittest.TestCase): ) ) - def test_get_roles_returns_roles_to_delete(self): - """Roles to be deleted should be returned as the third tuple element.""" - 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), - } + def test_diff_for_deleted_roles(self): + """Only deleted roles should be added to the 'deleted' set of the diff.""" + deleted_role = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) + self.bot.api_client.get.return_value = [self.constant_role, deleted_role] + guild = self.get_guild(self.constant_role) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), {_Role(**deleted_role)}) + + self.assertEqual(actual_diff, expected_diff) def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): """When roles were added, updated, and removed, all of them are returned properly.""" -- cgit v1.2.3 From f17a61ac8426bf756ee1f236bbd8f0e33d4932b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:27:00 -0800 Subject: Sync tests: test diff for all 3 role changes simultaneously --- tests/bot/cogs/sync/test_roles.py | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 694ee6276..ccd617463 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -62,7 +62,7 @@ class RoleSyncerTests(unittest.TestCase): self.bot.api_client.get.return_value = [self.constant_role] guild_roles = [ self.constant_role, - {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, ] guild = self.get_guild(*guild_roles) @@ -103,24 +103,20 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): - """When roles were added, updated, and removed, all of them are returned properly.""" - 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), - } + def test_diff_for_new_updated_and_deleted_roles(self): + """When roles are added, updated, and removed, all of them are returned properly.""" + new = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + updated = {"id": 71, "name": "updated", "colour": 101, "permissions": 0x5, "position": 4} + deleted = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} - self.assertEqual( - 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)}, - ) - ) + self.bot.api_client.get.return_value = [ + self.constant_role, + {"id": 71, "name": "update", "colour": 99, "permissions": 0x9, "position": 4}, + deleted, + ] + guild = self.get_guild(self.constant_role, new, updated) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) + + self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From d212fb724be9ac6ab05671f28113318113a4bbe3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:27:51 -0800 Subject: Sync tests: remove diff test for updated and new roles together Redundant since test_diff_for_new_updated_and_deleted_roles tests all 3 types together. --- tests/bot/cogs/sync/test_roles.py | 19 ------------------- 1 file changed, 19 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index ccd617463..ca9df4305 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -72,25 +72,6 @@ class RoleSyncerTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_roles_returns_roles_to_update_and_new_roles(self): - """Newly created and updated roles should be returned together.""" - 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) - } - - self.assertEqual( - 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_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" deleted_role = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} -- cgit v1.2.3 From 86cdf82bc7fc96334994f8289f77ea3a6a14828b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 09:31:09 -0800 Subject: Sync tests: remove guild_roles lists and assign roles to variables Makes the creation of the expected diff clearer since the variable has a name compared to accessing some index of a list. --- tests/bot/cogs/sync/test_roles.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index ca9df4305..b9a4fe6cd 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -41,34 +41,28 @@ class RoleSyncerTests(unittest.TestCase): def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" + updated_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + self.bot.api_client.get.return_value = [ {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, self.constant_role, ] - guild_roles = [ - {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, - self.constant_role, - ] - - guild = self.get_guild(*guild_roles) + guild = self.get_guild(updated_role, self.constant_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) - expected_diff = (set(), {_Role(**guild_roles[0])}, set()) + expected_diff = (set(), {_Role(**updated_role)}, set()) self.assertEqual(actual_diff, expected_diff) def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" - self.bot.api_client.get.return_value = [self.constant_role] - guild_roles = [ - self.constant_role, - {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1}, - ] + new_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} - guild = self.get_guild(*guild_roles) + self.bot.api_client.get.return_value = [self.constant_role] + guild = self.get_guild(self.constant_role, new_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) - expected_diff = ({_Role(**guild_roles[1])}, set(), set()) + expected_diff = ({_Role(**new_role)}, set(), set()) self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From 7c39e44e5c611e01edb0510e23c69dc316ffd184 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 10:16:54 -0800 Subject: Sync tests: create separate role test cases for diff and sync tests --- tests/bot/cogs/sync/test_roles.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index b9a4fe6cd..10818a501 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -7,8 +7,8 @@ from bot.cogs.sync.syncers import RoleSyncer, _Role from tests import helpers -class RoleSyncerTests(unittest.TestCase): - """Tests constructing the roles to synchronize with the site.""" +class RoleSyncerDiffTests(unittest.TestCase): + """Tests for determining differences between roles in the DB and roles in the Guild cache.""" def setUp(self): self.bot = helpers.MockBot() @@ -95,3 +95,11 @@ class RoleSyncerTests(unittest.TestCase): expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) self.assertEqual(actual_diff, expected_diff) + + +class RoleSyncerSyncTests(unittest.TestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = RoleSyncer(self.bot) -- cgit v1.2.3 From dd07547977a4d49d34ebf597d6072d274b2e4feb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 10:22:11 -0800 Subject: Sync tests: test API requests for role syncing --- tests/bot/cogs/sync/test_roles.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 10818a501..719c93d7a 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -3,7 +3,7 @@ import unittest import discord -from bot.cogs.sync.syncers import RoleSyncer, _Role +from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role from tests import helpers @@ -103,3 +103,36 @@ class RoleSyncerSyncTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) + + def test_sync_created_role(self): + """Only a POST request should be made with the correct payload.""" + role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + diff = _Diff({_Role(**role)}, set(), set()) + + asyncio.run(self.syncer._sync(diff)) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role) + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + def test_sync_updated_role(self): + """Only a PUT request should be made with the correct payload.""" + role = {"id": 51, "name": "updated", "colour": 44, "permissions": 0x7, "position": 2} + diff = _Diff(set(), {_Role(**role)}, set()) + + asyncio.run(self.syncer._sync(diff)) + + self.bot.api_client.put.assert_called_once_with(f"bot/roles/{role['id']}", json=role) + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + def test_sync_deleted_role(self): + """Only a DELETE request should be made with the correct payload.""" + role = {"id": 61, "name": "deleted", "colour": 55, "permissions": 0x6, "position": 3} + diff = _Diff(set(), set(), {_Role(**role)}) + + asyncio.run(self.syncer._sync(diff)) + + self.bot.api_client.delete.assert_called_once_with(f"bot/roles/{role['id']}") + self.bot.api_client.post.assert_not_called() + self.bot.api_client.put.assert_not_called() -- cgit v1.2.3 From dc3841f5d737a2f697f62970186205c7b12d825e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 12:20:18 -0800 Subject: Sync tests: test syncs with multiple roles --- tests/bot/cogs/sync/test_roles.py | 52 ++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 15 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 719c93d7a..389985bc3 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,5 +1,6 @@ import asyncio import unittest +from unittest import mock import discord @@ -104,35 +105,56 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - def test_sync_created_role(self): - """Only a POST request should be made with the correct payload.""" - role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} - diff = _Diff({_Role(**role)}, set(), set()) + def test_sync_created_roles(self): + """Only POST requests should be made with the correct payload.""" + roles = [ + {"id": 111, "name": "new", "colour": 4, "permissions": 0x7, "position": 1}, + {"id": 222, "name": "new2", "colour": 44, "permissions": 0x7, "position": 11}, + ] + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(role_tuples, set(), set()) asyncio.run(self.syncer._sync(diff)) - self.bot.api_client.post.assert_called_once_with("bot/roles", json=role) + calls = [mock.call("bot/roles", json=role) for role in roles] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(roles)) + self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_updated_role(self): - """Only a PUT request should be made with the correct payload.""" - role = {"id": 51, "name": "updated", "colour": 44, "permissions": 0x7, "position": 2} - diff = _Diff(set(), {_Role(**role)}, set()) + def test_sync_updated_roles(self): + """Only PUT requests should be made with the correct payload.""" + roles = [ + {"id": 333, "name": "updated", "colour": 5, "permissions": 0x7, "position": 2}, + {"id": 444, "name": "updated2", "colour": 55, "permissions": 0x7, "position": 22}, + ] + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), role_tuples, set()) asyncio.run(self.syncer._sync(diff)) - self.bot.api_client.put.assert_called_once_with(f"bot/roles/{role['id']}", json=role) + calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(roles)) + self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_deleted_role(self): - """Only a DELETE request should be made with the correct payload.""" - role = {"id": 61, "name": "deleted", "colour": 55, "permissions": 0x6, "position": 3} - diff = _Diff(set(), set(), {_Role(**role)}) + def test_sync_deleted_roles(self): + """Only DELETE requests should be made with the correct payload.""" + roles = [ + {"id": 555, "name": "deleted", "colour": 6, "permissions": 0x7, "position": 3}, + {"id": 666, "name": "deleted2", "colour": 66, "permissions": 0x7, "position": 33}, + ] + role_tuples = {_Role(**role) for role in roles} + diff = _Diff(set(), set(), role_tuples) asyncio.run(self.syncer._sync(diff)) - self.bot.api_client.delete.assert_called_once_with(f"bot/roles/{role['id']}") + calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] + self.bot.api_client.delete.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.delete.call_count, len(roles)) + self.bot.api_client.post.assert_not_called() self.bot.api_client.put.assert_not_called() -- cgit v1.2.3 From aa9f5a5eb96cfdf3482f94b0484eed1e54c3b75e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 13:52:38 -0800 Subject: Sync tests: rename user sync test case --- tests/bot/cogs/sync/test_users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index ccaf67490..509b703ae 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -13,8 +13,8 @@ def fake_user(**kwargs): return User(**kwargs) -class GetUsersForSyncTests(unittest.TestCase): - """Tests constructing the users to synchronize with the site.""" +class UserSyncerDiffTests(unittest.TestCase): + """Tests for determining differences between users in the DB and users in the Guild cache.""" def test_get_users_for_sync_returns_nothing_for_empty_params(self): """When no users are given, none are returned.""" -- cgit v1.2.3 From 7a8c71b7cd5b446188b053aef139255af7bf0154 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 19:31:08 -0800 Subject: Sync tests: add fixture to get a guild with members --- tests/bot/cogs/sync/test_users.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 509b703ae..83a9cdaf0 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,6 +1,7 @@ import unittest -from bot.cogs.sync.syncers import User, get_users_for_sync +from bot.cogs.sync.syncers import UserSyncer +from tests import helpers def fake_user(**kwargs): @@ -16,6 +17,23 @@ def fake_user(**kwargs): class UserSyncerDiffTests(unittest.TestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + @staticmethod + def get_guild(*members): + """Fixture to return a guild object with the given members.""" + guild = helpers.MockGuild() + guild.members = [] + + for member in members: + roles = (helpers.MockRole(id=role_id) for role_id in member.pop("roles")) + mock_member = helpers.MockMember(roles, **member) + guild.members.append(mock_member) + + return guild + def test_get_users_for_sync_returns_nothing_for_empty_params(self): """When no users are given, none are returned.""" self.assertEqual( -- cgit v1.2.3 From f263877518562e33b661e70f6ea3e8f3b1ab914b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 2 Jan 2020 19:34:25 -0800 Subject: Sync tests: test empty diff for no users --- tests/bot/cogs/sync/test_users.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 83a9cdaf0..b5175a27c 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,3 +1,4 @@ +import asyncio import unittest from bot.cogs.sync.syncers import UserSyncer @@ -34,12 +35,14 @@ class UserSyncerDiffTests(unittest.TestCase): return guild - def test_get_users_for_sync_returns_nothing_for_empty_params(self): - """When no users are given, none are returned.""" - self.assertEqual( - get_users_for_sync({}, {}), - (set(), set()) - ) + def test_empty_diff_for_no_users(self): + """When no users are given, an empty diff should be returned.""" + guild = self.get_guild() + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_returns_nothing_for_equal_users(self): """When no users are updated, none are returned.""" -- cgit v1.2.3 From 7036a9a32651ee0cfb820f994a7332f024169579 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:01:48 -0800 Subject: Sync tests: fix fake_user fixture --- tests/bot/cogs/sync/test_users.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index b5175a27c..f3d88c59f 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -6,13 +6,15 @@ from tests import helpers 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) + """Fixture to return a dictionary representing a user with default values set.""" + 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 kwargs class UserSyncerDiffTests(unittest.TestCase): -- cgit v1.2.3 From f49d50164cc8afcf1245f3ec47b7963c6874ece6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:15:33 -0800 Subject: Sync tests: fix mismatched attributes when creating a mock user --- tests/bot/cogs/sync/test_users.py | 3 +++ 1 file changed, 3 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index f3d88c59f..4c79c51c5 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -32,6 +32,9 @@ class UserSyncerDiffTests(unittest.TestCase): for member in members: roles = (helpers.MockRole(id=role_id) for role_id in member.pop("roles")) + member["avatar"] = member.pop("avatar_hash") + del member["in_guild"] + mock_member = helpers.MockMember(roles, **member) guild.members.append(mock_member) -- cgit v1.2.3 From eab415b61122de4c039b229390e1d6c180d101da Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:53:32 -0800 Subject: Sync tests: work around @everyone role being added by MockMember --- tests/bot/cogs/sync/test_users.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 4c79c51c5..3dd2942b5 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -31,11 +31,12 @@ class UserSyncerDiffTests(unittest.TestCase): guild.members = [] for member in members: - roles = (helpers.MockRole(id=role_id) for role_id in member.pop("roles")) member["avatar"] = member.pop("avatar_hash") del member["in_guild"] - mock_member = helpers.MockMember(roles, **member) + mock_member = helpers.MockMember(**member) + mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] + guild.members.append(mock_member) return guild -- cgit v1.2.3 From 4912e94e3079b01b9481dee785c0b7f2552f7a1b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 10:53:45 -0800 Subject: Sync tests: test empty diff for identical users --- tests/bot/cogs/sync/test_users.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 3dd2942b5..7a4a85c96 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -50,15 +50,15 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_returns_nothing_for_equal_users(self): - """When no users are updated, none are returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user()} + def test_empty_diff_for_identical_users(self): + """No differences should be found if the users in the guild and DB are identical.""" + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user()) - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): """When a non-ID-field differs, the user to update is returned.""" -- cgit v1.2.3 From c53cc07217faa15f56c60c3b36aefbb7676e6011 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:20:07 -0800 Subject: Sync tests: fix get_guild modifying the original member dicts --- tests/bot/cogs/sync/test_users.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 7a4a85c96..0d00e6970 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -31,6 +31,7 @@ class UserSyncerDiffTests(unittest.TestCase): guild.members = [] for member in members: + member = member.copy() member["avatar"] = member.pop("avatar_hash") del member["in_guild"] -- cgit v1.2.3 From e74d360e3834511ffa2fb93f1146cda664a403a5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:20:33 -0800 Subject: Sync tests: test diff for updated users --- tests/bot/cogs/sync/test_users.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 0d00e6970..f1084fa98 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,7 +1,7 @@ import asyncio import unittest -from bot.cogs.sync.syncers import UserSyncer +from bot.cogs.sync.syncers import UserSyncer, _User from tests import helpers @@ -61,15 +61,17 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): - """When a non-ID-field differs, the user to update is returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} + def test_diff_for_updated_users(self): + """Only updated users should be added to the 'updated' set of the diff.""" + updated_user = fake_user(id=99, name="new") - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(name='new fancy name')}) - ) + self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] + guild = self.get_guild(updated_user, fake_user()) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), {_User(**updated_user)}, None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): """When new users join the guild, they are returned as the first tuple element.""" -- cgit v1.2.3 From 30ebb0184d12000db3ae5f276395fecd52d5dfa5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:22:46 -0800 Subject: Sync tests: test diff for new users --- tests/bot/cogs/sync/test_users.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index f1084fa98..c8ce7c04d 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -73,15 +73,17 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): - """When new users join the guild, they are returned as the first tuple element.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} + def test_diff_for_new_users(self): + """Only new users should be added to the 'created' set of the diff.""" + new_user = fake_user(id=99, name="new") - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, set()) - ) + self.bot.api_client.get.return_value = [fake_user()] + guild = self.get_guild(fake_user(), new_user) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_User(**new_user)}, set(), None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" -- cgit v1.2.3 From 16f7eda6005b974ee2bc77f0440e05afad46c8e7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:34:34 -0800 Subject: Sync tests: test diff for users which leave the guild --- tests/bot/cogs/sync/test_users.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index c8ce7c04d..faa5918df 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -85,15 +85,17 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): + def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} + leaving_user = fake_user(id=63, in_guild=False) - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(id=63, in_guild=False)}) - ) + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] + guild = self.get_guild(fake_user()) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), {_User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_updates_and_creates_users_as_needed(self): """When one user left and another one was updated, both are returned.""" -- cgit v1.2.3 From 6401306228526250092fece786640be281eac812 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:43:35 -0800 Subject: Sync tests: test diff for all 3 changes simultaneously --- tests/bot/cogs/sync/test_users.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index faa5918df..ff863a929 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -97,15 +97,19 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_updates_and_creates_users_as_needed(self): - """When one user left and another one was updated, both are returned.""" - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} + def test_diff_for_new_updated_and_leaving_users(self): + """When users are added, updated, and removed, all of them are returned properly.""" + new_user = fake_user(id=99, name="new") + updated_user = fake_user(id=55, name="updated") + leaving_user = fake_user(id=63, in_guild=False) - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, {fake_user(in_guild=False)}) - ) + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] + guild = self.get_guild(fake_user(), new_user, updated_user) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + + self.assertEqual(actual_diff, expected_diff) def test_get_users_for_sync_does_not_duplicate_update_users(self): """When the API knows a user the guild doesn't, nothing is performed.""" -- cgit v1.2.3 From 01d7b53180864b1e47ebc8c831a706dc1a3c0d79 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:45:47 -0800 Subject: Sync tests: test diff is empty when DB has a user not in the guild --- tests/bot/cogs/sync/test_users.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index ff863a929..dfb9ac405 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -111,12 +111,12 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - def test_get_users_for_sync_does_not_duplicate_update_users(self): - """When the API knows a user the guild doesn't, nothing is performed.""" - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) + def test_empty_diff_for_db_users_not_in_guild(self): + """When the DB knows a user the guild doesn't, no difference is found.""" + self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] + guild = self.get_guild(fake_user()) + + actual_diff = asyncio.run(self.syncer._get_diff(guild)) + expected_diff = (set(), set(), None) + + self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From 155c4c7a1bb73ef42cf19ccacc612c7a5bc17201 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 11:54:39 -0800 Subject: Sync tests: add tests for API requests for syncing users --- tests/bot/cogs/sync/test_users.py | 41 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index dfb9ac405..7fc1b400f 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,7 +1,8 @@ import asyncio import unittest +from unittest import mock -from bot.cogs.sync.syncers import UserSyncer, _User +from bot.cogs.sync.syncers import UserSyncer, _Diff, _User from tests import helpers @@ -120,3 +121,41 @@ class UserSyncerDiffTests(unittest.TestCase): expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) + + +class UserSyncerSyncTests(unittest.TestCase): + """Tests for the API requests that sync roles.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = UserSyncer(self.bot) + + def test_sync_created_users(self): + """Only POST requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(user_tuples, set(), None) + asyncio.run(self.syncer._sync(diff)) + + calls = [mock.call("bot/users", json=user) for user in users] + self.bot.api_client.post.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.post.call_count, len(users)) + + self.bot.api_client.put.assert_not_called() + self.bot.api_client.delete.assert_not_called() + + def test_sync_updated_users(self): + """Only PUT requests should be made with the correct payload.""" + users = [fake_user(id=111), fake_user(id=222)] + + user_tuples = {_User(**user) for user in users} + diff = _Diff(set(), user_tuples, None) + asyncio.run(self.syncer._sync(diff)) + + calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] + self.bot.api_client.put.assert_has_calls(calls, any_order=True) + self.assertEqual(self.bot.api_client.put.call_count, len(users)) + + self.bot.api_client.post.assert_not_called() + self.bot.api_client.delete.assert_not_called() -- cgit v1.2.3 From b5febafba40e3de655b723eed274ac94919a395e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 12:01:47 -0800 Subject: Sync tests: create and use a fake_role fixture --- tests/bot/cogs/sync/test_roles.py | 64 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 33 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 389985bc3..8324b99cd 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -8,13 +8,23 @@ from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role from tests import helpers +def fake_role(**kwargs): + """Fixture to return a dictionary representing a role with default values set.""" + kwargs.setdefault("id", 9) + kwargs.setdefault("name", "fake role") + kwargs.setdefault("colour", 7) + kwargs.setdefault("permissions", 0) + kwargs.setdefault("position", 55) + + return kwargs + + class RoleSyncerDiffTests(unittest.TestCase): """Tests for determining differences between roles in the DB and roles in the Guild cache.""" def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - self.constant_role = {"id": 9, "name": "test", "colour": 7, "permissions": 0, "position": 3} @staticmethod def get_guild(*roles): @@ -32,8 +42,8 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_empty_diff_for_identical_roles(self): """No differences should be found if the roles in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [self.constant_role] - guild = self.get_guild(self.constant_role) + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role()) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), set(), set()) @@ -42,13 +52,10 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" - updated_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + updated_role = fake_role(id=41, name="new") - self.bot.api_client.get.return_value = [ - {"id": 41, "name": "old", "colour": 33, "permissions": 0x8, "position": 1}, - self.constant_role, - ] - guild = self.get_guild(updated_role, self.constant_role) + self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] + guild = self.get_guild(updated_role, fake_role()) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), {_Role(**updated_role)}, set()) @@ -57,10 +64,10 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" - new_role = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} + new_role = fake_role(id=41, name="new") - self.bot.api_client.get.return_value = [self.constant_role] - guild = self.get_guild(self.constant_role, new_role) + self.bot.api_client.get.return_value = [fake_role()] + guild = self.get_guild(fake_role(), new_role) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = ({_Role(**new_role)}, set(), set()) @@ -69,10 +76,10 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" - deleted_role = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} + deleted_role = fake_role(id=61, name="deleted") - self.bot.api_client.get.return_value = [self.constant_role, deleted_role] - guild = self.get_guild(self.constant_role) + self.bot.api_client.get.return_value = [fake_role(), deleted_role] + guild = self.get_guild(fake_role()) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = (set(), set(), {_Role(**deleted_role)}) @@ -81,16 +88,16 @@ class RoleSyncerDiffTests(unittest.TestCase): def test_diff_for_new_updated_and_deleted_roles(self): """When roles are added, updated, and removed, all of them are returned properly.""" - new = {"id": 41, "name": "new", "colour": 33, "permissions": 0x8, "position": 1} - updated = {"id": 71, "name": "updated", "colour": 101, "permissions": 0x5, "position": 4} - deleted = {"id": 61, "name": "delete", "colour": 99, "permissions": 0x9, "position": 2} + new = fake_role(id=41, name="new") + updated = fake_role(id=71, name="updated") + deleted = fake_role(id=61, name="deleted") self.bot.api_client.get.return_value = [ - self.constant_role, - {"id": 71, "name": "update", "colour": 99, "permissions": 0x9, "position": 4}, + fake_role(), + fake_role(id=71, name="updated name"), deleted, ] - guild = self.get_guild(self.constant_role, new, updated) + guild = self.get_guild(fake_role(), new, updated) actual_diff = asyncio.run(self.syncer._get_diff(guild)) expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) @@ -107,10 +114,7 @@ class RoleSyncerSyncTests(unittest.TestCase): def test_sync_created_roles(self): """Only POST requests should be made with the correct payload.""" - roles = [ - {"id": 111, "name": "new", "colour": 4, "permissions": 0x7, "position": 1}, - {"id": 222, "name": "new2", "colour": 44, "permissions": 0x7, "position": 11}, - ] + roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(role_tuples, set(), set()) @@ -125,10 +129,7 @@ class RoleSyncerSyncTests(unittest.TestCase): def test_sync_updated_roles(self): """Only PUT requests should be made with the correct payload.""" - roles = [ - {"id": 333, "name": "updated", "colour": 5, "permissions": 0x7, "position": 2}, - {"id": 444, "name": "updated2", "colour": 55, "permissions": 0x7, "position": 22}, - ] + roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), role_tuples, set()) @@ -143,10 +144,7 @@ class RoleSyncerSyncTests(unittest.TestCase): def test_sync_deleted_roles(self): """Only DELETE requests should be made with the correct payload.""" - roles = [ - {"id": 555, "name": "deleted", "colour": 6, "permissions": 0x7, "position": 3}, - {"id": 666, "name": "deleted2", "colour": 66, "permissions": 0x7, "position": 33}, - ] + roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), set(), role_tuples) -- cgit v1.2.3 From 396d2b393a255580ea23c3cc4abb4bdb1e84ea7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 12:08:44 -0800 Subject: Sync tests: fix docstring for UserSyncerSyncTests --- tests/bot/cogs/sync/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 7fc1b400f..e9f9db2ea 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -124,7 +124,7 @@ class UserSyncerDiffTests(unittest.TestCase): class UserSyncerSyncTests(unittest.TestCase): - """Tests for the API requests that sync roles.""" + """Tests for the API requests that sync users.""" def setUp(self): self.bot = helpers.MockBot() -- cgit v1.2.3 From f6c78b63bccc36526d8ee8072a27e0678db0781a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 3 Jan 2020 12:12:00 -0800 Subject: Sync tests: fix wait_until_ready in duck pond tests --- tests/bot/cogs/test_duck_pond.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index d07b2bce1..5b0a3b8c3 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -54,7 +54,7 @@ class DuckPondTests(base.LoggingTestCase): asyncio.run(self.cog.fetch_webhook()) - self.bot.wait_until_ready.assert_called_once() + self.bot.wait_until_guild_available.assert_called_once() self.bot.fetch_webhook.assert_called_once_with(1) self.assertEqual(self.cog.webhook, "dummy webhook") @@ -67,7 +67,7 @@ class DuckPondTests(base.LoggingTestCase): with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: asyncio.run(self.cog.fetch_webhook()) - self.bot.wait_until_ready.assert_called_once() + self.bot.wait_until_guild_available.assert_called_once() self.bot.fetch_webhook.assert_called_once_with(1) self.assertEqual(len(log_watcher.records), 1) -- cgit v1.2.3 From 5024a75004f8d9f4726017af74cace6c1ab6c501 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 4 Jan 2020 10:25:33 -0800 Subject: Sync tests: test instantiation fails without abstract methods --- tests/bot/cogs/sync/test_base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/bot/cogs/sync/test_base.py (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py new file mode 100644 index 000000000..79ec86fee --- /dev/null +++ b/tests/bot/cogs/sync/test_base.py @@ -0,0 +1,17 @@ +import unittest + +from bot.cogs.sync.syncers import Syncer +from tests import helpers + + +class SyncerBaseTests(unittest.TestCase): + """Tests for the syncer base class.""" + + def setUp(self): + self.bot = helpers.MockBot() + + def test_instantiation_fails_without_abstract_methods(self): + """The class must have abstract methods implemented.""" + with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): + Syncer(self.bot) + -- cgit v1.2.3 From c4caf865ce677a8d1d827cbd1107338c251ff90b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 4 Jan 2020 10:27:48 -0800 Subject: Sync tests: create a Syncer subclass for testing --- tests/bot/cogs/sync/test_base.py | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 79ec86fee..d38c90410 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -4,11 +4,20 @@ from bot.cogs.sync.syncers import Syncer from tests import helpers +class TestSyncer(Syncer): + """Syncer subclass with mocks for abstract methods for testing purposes.""" + + name = "test" + _get_diff = helpers.AsyncMock() + _sync = helpers.AsyncMock() + + class SyncerBaseTests(unittest.TestCase): """Tests for the syncer base class.""" def setUp(self): self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) def test_instantiation_fails_without_abstract_methods(self): """The class must have abstract methods implemented.""" -- cgit v1.2.3 From 113029aae7625118ac1a5491652f3960172a3605 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 09:41:28 -0800 Subject: Sync tests: test that _send_prompt edits message contents --- tests/bot/cogs/sync/test_base.py | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index d38c90410..048d6c533 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,3 +1,4 @@ +import asyncio import unittest from bot.cogs.sync.syncers import Syncer @@ -24,3 +25,10 @@ class SyncerBaseTests(unittest.TestCase): with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): Syncer(self.bot) + def test_send_prompt_edits_message_content(self): + """The contents of the given message should be edited to display the prompt.""" + msg = helpers.MockMessage() + asyncio.run(self.syncer._send_prompt(msg)) + + msg.edit.assert_called_once() + self.assertIn("content", msg.edit.call_args[1]) -- cgit v1.2.3 From e6bb9a79faad03ea7c3a373af84f707722da106f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 10:43:34 -0800 Subject: Sync tests: test that _send_prompt gets channel from cache --- tests/bot/cogs/sync/test_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 048d6c533..9b177f25c 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -2,6 +2,7 @@ import asyncio import unittest from bot.cogs.sync.syncers import Syncer +from bot import constants from tests import helpers @@ -32,3 +33,13 @@ class SyncerBaseTests(unittest.TestCase): msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) + + def test_send_prompt_gets_channel_from_cache(self): + """The dev-core channel should be retrieved from cache if an extant message isn't given.""" + mock_channel = helpers.MockTextChannel() + mock_channel.send.return_value = helpers.MockMessage() + self.bot.get_channel.return_value = mock_channel + + asyncio.run(self.syncer._send_prompt()) + + self.bot.get_channel.assert_called_once_with(constants.Channels.devcore) -- cgit v1.2.3 From a6f01dbd55aef97a39f615348ea22b62a59f2c70 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 10:44:14 -0800 Subject: Sync tests: test _send_prompt fetches channel on a cache miss --- tests/bot/cogs/sync/test_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 9b177f25c..c18fa5fbb 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -43,3 +43,14 @@ class SyncerBaseTests(unittest.TestCase): asyncio.run(self.syncer._send_prompt()) self.bot.get_channel.assert_called_once_with(constants.Channels.devcore) + + def test_send_prompt_fetches_channel_if_cache_miss(self): + """The dev-core channel should be fetched with an API call if it's not in the cache.""" + self.bot.get_channel.return_value = None + mock_channel = helpers.MockTextChannel() + mock_channel.send.return_value = helpers.MockMessage() + self.bot.fetch_channel.return_value = mock_channel + + asyncio.run(self.syncer._send_prompt()) + + self.bot.fetch_channel.assert_called_once_with(constants.Channels.devcore) -- cgit v1.2.3 From 6f116956395fa1b48233a3014d215a3704b929ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 10:44:31 -0800 Subject: Sync tests: test _send_prompt returns None if channel fetch fails --- tests/bot/cogs/sync/test_base.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index c18fa5fbb..8eecea53f 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,5 +1,8 @@ import asyncio import unittest +from unittest import mock + +import discord from bot.cogs.sync.syncers import Syncer from bot import constants @@ -54,3 +57,12 @@ class SyncerBaseTests(unittest.TestCase): asyncio.run(self.syncer._send_prompt()) self.bot.fetch_channel.assert_called_once_with(constants.Channels.devcore) + + def test_send_prompt_returns_None_if_channel_fetch_fails(self): + """None should be returned if there's an HTTPException when fetching the channel.""" + self.bot.get_channel.return_value = None + self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") + + ret_val = asyncio.run(self.syncer._send_prompt()) + + self.assertIsNone(ret_val) -- cgit v1.2.3 From d57db0b39b52b4660986e90d308434c823428b71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 11:06:44 -0800 Subject: Sync tests: test _send_prompt sends a new message if one isn't given --- tests/bot/cogs/sync/test_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 8eecea53f..f4ea33823 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -66,3 +66,14 @@ class SyncerBaseTests(unittest.TestCase): ret_val = asyncio.run(self.syncer._send_prompt()) self.assertIsNone(ret_val) + + def test_send_prompt_sends_new_message_if_not_given(self): + """A new message that mentions core devs should be sent if an extant message isn't given.""" + mock_channel = helpers.MockTextChannel() + mock_channel.send.return_value = helpers.MockMessage() + self.bot.get_channel.return_value = mock_channel + + asyncio.run(self.syncer._send_prompt()) + + mock_channel.send.assert_called_once() + self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) -- cgit v1.2.3 From 3298312ad182dd1a8a5c9596d7bdc1d6f4905ebf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 5 Jan 2020 11:23:55 -0800 Subject: Sync tests: test _send_prompt adds reactions --- tests/bot/cogs/sync/test_base.py | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index f4ea33823..e509b3c98 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -77,3 +77,11 @@ class SyncerBaseTests(unittest.TestCase): mock_channel.send.assert_called_once() self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) + + def test_send_prompt_adds_reactions(self): + """The message should have reactions for confirmation added.""" + msg = helpers.MockMessage() + asyncio.run(self.syncer._send_prompt(msg)) + + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + msg.add_reaction.assert_has_calls(calls) -- cgit v1.2.3 From 0fdd675f5bc85a20268e257e073d9605126ee322 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 09:56:34 -0800 Subject: Sync tests: add fixtures to mock dev core channel get and fetch --- tests/bot/cogs/sync/test_base.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e509b3c98..2c6857246 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -24,6 +24,27 @@ class SyncerBaseTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) + def mock_dev_core_channel(self): + """Fixture to return a mock channel and message for when `get_channel` is used.""" + mock_channel = helpers.MockTextChannel() + mock_message = helpers.MockMessage() + + mock_channel.send.return_value = mock_message + self.bot.get_channel.return_value = mock_channel + + return mock_channel, mock_message + + def mock_dev_core_channel_cache_miss(self): + """Fixture to return a mock channel and message for when `fetch_channel` is used.""" + mock_channel = helpers.MockTextChannel() + mock_message = helpers.MockMessage() + + self.bot.get_channel.return_value = None + mock_channel.send.return_value = mock_message + self.bot.fetch_channel.return_value = mock_channel + + return mock_channel, mock_message + def test_instantiation_fails_without_abstract_methods(self): """The class must have abstract methods implemented.""" with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): -- cgit v1.2.3 From 7d3b46741cfd12d2f8cc40107464f7b3210b9af5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 10:39:20 -0800 Subject: Sync tests: reset mocks in channel fixtures --- tests/bot/cogs/sync/test_base.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 2c6857246..ff67eb334 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -26,6 +26,8 @@ class SyncerBaseTests(unittest.TestCase): def mock_dev_core_channel(self): """Fixture to return a mock channel and message for when `get_channel` is used.""" + self.bot.reset_mock() + mock_channel = helpers.MockTextChannel() mock_message = helpers.MockMessage() @@ -36,6 +38,8 @@ class SyncerBaseTests(unittest.TestCase): def mock_dev_core_channel_cache_miss(self): """Fixture to return a mock channel and message for when `fetch_channel` is used.""" + self.bot.reset_mock() + mock_channel = helpers.MockTextChannel() mock_message = helpers.MockMessage() -- cgit v1.2.3 From 7215a9483cb9ebae89d147f950dd62996d86beeb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 10:45:02 -0800 Subject: Sync tests: rename channel fixtures --- tests/bot/cogs/sync/test_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ff67eb334..1d61f8cb2 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -24,7 +24,7 @@ class SyncerBaseTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - def mock_dev_core_channel(self): + def mock_get_channel(self): """Fixture to return a mock channel and message for when `get_channel` is used.""" self.bot.reset_mock() @@ -36,7 +36,7 @@ class SyncerBaseTests(unittest.TestCase): return mock_channel, mock_message - def mock_dev_core_channel_cache_miss(self): + def mock_fetch_channel(self): """Fixture to return a mock channel and message for when `fetch_channel` is used.""" self.bot.reset_mock() -- cgit v1.2.3 From 1cef637f4d53ba1a093403f4e237e6004330cc1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 10:46:16 -0800 Subject: Sync tests: use channel fixtures with subtests * Merge test_send_prompt_fetches_channel_if_cache_miss into test_send_prompt_gets_channel_from_cache * Rename test_send_prompt_gets_channel_from_cache * Test test_send_prompt_sends_new_message_if_not_given with fetch_channel too --- tests/bot/cogs/sync/test_base.py | 42 ++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 25 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 1d61f8cb2..d46965738 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -62,26 +62,19 @@ class SyncerBaseTests(unittest.TestCase): msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) - def test_send_prompt_gets_channel_from_cache(self): - """The dev-core channel should be retrieved from cache if an extant message isn't given.""" - mock_channel = helpers.MockTextChannel() - mock_channel.send.return_value = helpers.MockMessage() - self.bot.get_channel.return_value = mock_channel - - asyncio.run(self.syncer._send_prompt()) + def test_send_prompt_gets_dev_core_channel(self): + """The dev-core channel should be retrieved if an extant message isn't given.""" + subtests = ( + (self.bot.get_channel, self.mock_get_channel), + (self.bot.fetch_channel, self.mock_fetch_channel), + ) - self.bot.get_channel.assert_called_once_with(constants.Channels.devcore) + for method, mock_ in subtests: + with self.subTest(method=method, msg=mock_.__name__): + mock_() + asyncio.run(self.syncer._send_prompt()) - def test_send_prompt_fetches_channel_if_cache_miss(self): - """The dev-core channel should be fetched with an API call if it's not in the cache.""" - self.bot.get_channel.return_value = None - mock_channel = helpers.MockTextChannel() - mock_channel.send.return_value = helpers.MockMessage() - self.bot.fetch_channel.return_value = mock_channel - - asyncio.run(self.syncer._send_prompt()) - - self.bot.fetch_channel.assert_called_once_with(constants.Channels.devcore) + method.assert_called_once_with(constants.Channels.devcore) def test_send_prompt_returns_None_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" @@ -94,14 +87,13 @@ class SyncerBaseTests(unittest.TestCase): def test_send_prompt_sends_new_message_if_not_given(self): """A new message that mentions core devs should be sent if an extant message isn't given.""" - mock_channel = helpers.MockTextChannel() - mock_channel.send.return_value = helpers.MockMessage() - self.bot.get_channel.return_value = mock_channel - - asyncio.run(self.syncer._send_prompt()) + for mock_ in (self.mock_get_channel, self.mock_fetch_channel): + with self.subTest(msg=mock_.__name__): + mock_channel, _ = mock_() + asyncio.run(self.syncer._send_prompt()) - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) + mock_channel.send.assert_called_once() + self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" -- cgit v1.2.3 From cc8ecb9fd52b24e323c4e6f5ce8a2ddcc8d31777 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 11:18:35 -0800 Subject: Sync tests: use channel fixtures with subtests in add reaction test --- tests/bot/cogs/sync/test_base.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index d46965738..e0a3f4127 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -97,8 +97,19 @@ class SyncerBaseTests(unittest.TestCase): def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" - msg = helpers.MockMessage() - asyncio.run(self.syncer._send_prompt(msg)) + extant_message = helpers.MockMessage() + subtests = ( + (extant_message, lambda: (None, extant_message)), + (None, self.mock_get_channel), + (None, self.mock_fetch_channel), + ) + + for message_arg, mock_ in subtests: + subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ + + with self.subTest(msg=subtest_msg): + _, mock_message = mock_() + asyncio.run(self.syncer._send_prompt(message_arg)) - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - msg.add_reaction.assert_has_calls(calls) + calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] + mock_message.add_reaction.assert_has_calls(calls) -- cgit v1.2.3 From 04dbf347e08d4e2a3690e59a537ab73544c82be6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jan 2020 11:34:05 -0800 Subject: Sync tests: test the return value of _send_prompt --- tests/bot/cogs/sync/test_base.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e0a3f4127..4c3eae1b3 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -54,13 +54,14 @@ class SyncerBaseTests(unittest.TestCase): with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): Syncer(self.bot) - def test_send_prompt_edits_message_content(self): - """The contents of the given message should be edited to display the prompt.""" + def test_send_prompt_edits_and_returns_message(self): + """The given message should be edited to display the prompt and then should be returned.""" msg = helpers.MockMessage() - asyncio.run(self.syncer._send_prompt(msg)) + ret_val = asyncio.run(self.syncer._send_prompt(msg)) msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) + self.assertEqual(ret_val, msg) def test_send_prompt_gets_dev_core_channel(self): """The dev-core channel should be retrieved if an extant message isn't given.""" @@ -85,15 +86,16 @@ class SyncerBaseTests(unittest.TestCase): self.assertIsNone(ret_val) - def test_send_prompt_sends_new_message_if_not_given(self): - """A new message that mentions core devs should be sent if an extant message isn't given.""" + def test_send_prompt_sends_and_returns_new_message_if_not_given(self): + """A new message mentioning core devs should be sent and returned if message isn't given.""" for mock_ in (self.mock_get_channel, self.mock_fetch_channel): with self.subTest(msg=mock_.__name__): - mock_channel, _ = mock_() - asyncio.run(self.syncer._send_prompt()) + mock_channel, mock_message = mock_() + ret_val = asyncio.run(self.syncer._send_prompt()) mock_channel.send.assert_called_once() self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) + self.assertEqual(ret_val, mock_message) def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" -- cgit v1.2.3 From d020e5ebaf72448b015351b550ea3c82bde3c61f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 8 Jan 2020 16:42:01 -0800 Subject: Sync tests: create a separate test case for _send_prompt tests --- tests/bot/cogs/sync/test_base.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 4c3eae1b3..af15b544b 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -20,6 +20,18 @@ class TestSyncer(Syncer): class SyncerBaseTests(unittest.TestCase): """Tests for the syncer base class.""" + def setUp(self): + self.bot = helpers.MockBot() + + def test_instantiation_fails_without_abstract_methods(self): + """The class must have abstract methods implemented.""" + with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): + Syncer(self.bot) + + +class SyncerSendPromptTests(unittest.TestCase): + """Tests for sending the sync confirmation prompt.""" + def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) @@ -49,11 +61,6 @@ class SyncerBaseTests(unittest.TestCase): return mock_channel, mock_message - def test_instantiation_fails_without_abstract_methods(self): - """The class must have abstract methods implemented.""" - with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): - Syncer(self.bot) - def test_send_prompt_edits_and_returns_message(self): """The given message should be edited to display the prompt and then should be returned.""" msg = helpers.MockMessage() -- cgit v1.2.3 From b43b0bc611a0ba7d7ee62bc94a11ac661772f3ca Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 10 Jan 2020 11:46:18 -0800 Subject: Sync tests: create a test suite for confirmation tests --- tests/bot/cogs/sync/test_base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index af15b544b..ca344c865 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -4,8 +4,8 @@ from unittest import mock import discord -from bot.cogs.sync.syncers import Syncer from bot import constants +from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -122,3 +122,11 @@ class SyncerSendPromptTests(unittest.TestCase): calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] mock_message.add_reaction.assert_has_calls(calls) + + +class SyncerConfirmationTests(unittest.TestCase): + """Tests for waiting for a sync confirmation reaction on the prompt.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) -- cgit v1.2.3 From 9a73feb93a7680211e597f0cc9d09b06ebc84335 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 10 Jan 2020 11:47:41 -0800 Subject: Sync tests: test _reaction_check for valid emoji and authors Should return True if authors are identical or are a bot and a core dev, respectively. * Create a mock core dev role in the setup fixture * Create a fixture to create a mock message and reaction from an emoji --- tests/bot/cogs/sync/test_base.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ca344c865..f722a83e8 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -130,3 +130,30 @@ class SyncerConfirmationTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developer) + + @staticmethod + def get_message_reaction(emoji): + """Fixture to return a mock message an reaction from the given `emoji`.""" + message = helpers.MockMessage() + reaction = helpers.MockReaction(emoji=emoji, message=message) + + return message, reaction + + def test_reaction_check_for_valid_emoji_and_authors(self): + """Should return True if authors are identical or are a bot and a core dev, respectively.""" + user_subtests = ( + (helpers.MockMember(id=77), helpers.MockMember(id=77)), + ( + helpers.MockMember(id=77, bot=True), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + ) + ) + + for emoji in self.syncer._REACTION_EMOJIS: + for author, user in user_subtests: + with self.subTest(author=author, user=user, emoji=emoji): + message, reaction = self.get_message_reaction(emoji) + ret_val = self.syncer._reaction_check(author, message, reaction, user) + + self.assertTrue(ret_val) -- cgit v1.2.3 From 7ac2d59c485cddc37ef3fd7ebe175cf5bef784fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Jan 2020 09:18:43 -0800 Subject: Sync tests: test _reaction_check for invalid reactions Should return False for invalid reaction events. --- tests/bot/cogs/sync/test_base.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index f722a83e8..43d72dda9 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -157,3 +157,46 @@ class SyncerConfirmationTests(unittest.TestCase): ret_val = self.syncer._reaction_check(author, message, reaction, user) self.assertTrue(ret_val) + + def test_reaction_check_for_invalid_reactions(self): + """Should return False for invalid reaction events.""" + valid_emoji = self.syncer._REACTION_EMOJIS[0] + subtests = ( + ( + helpers.MockMember(id=77), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43, roles=[self.core_dev_role]), + "users are not identical", + ), + ( + helpers.MockMember(id=77, bot=True), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=43), + "reactor lacks the core-dev role", + ), + ( + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + *self.get_message_reaction(valid_emoji), + helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), + "reactor is a bot", + ), + ( + helpers.MockMember(id=77), + helpers.MockMessage(id=95), + helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), + helpers.MockMember(id=77), + "messages are not identical", + ), + ( + helpers.MockMember(id=77), + *self.get_message_reaction("InVaLiD"), + helpers.MockMember(id=77), + "emoji is invalid", + ), + ) + + for *args, msg in subtests: + kwargs = dict(zip(("author", "message", "reaction", "user"), args)) + with self.subTest(**kwargs, msg=msg): + ret_val = self.syncer._reaction_check(*args) + self.assertFalse(ret_val) -- cgit v1.2.3 From ad402f5bc8f4db6b97f197fdb518a1b3e7f95eb5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Jan 2020 09:55:45 -0800 Subject: Sync tests: add messages to _reaction_check subtests The message will be displayed by the test runner when a subtest fails. --- tests/bot/cogs/sync/test_base.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 43d72dda9..2d682faad 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -143,16 +143,21 @@ class SyncerConfirmationTests(unittest.TestCase): def test_reaction_check_for_valid_emoji_and_authors(self): """Should return True if authors are identical or are a bot and a core dev, respectively.""" user_subtests = ( - (helpers.MockMember(id=77), helpers.MockMember(id=77)), + ( + helpers.MockMember(id=77), + helpers.MockMember(id=77), + "identical users", + ), ( helpers.MockMember(id=77, bot=True), helpers.MockMember(id=43, roles=[self.core_dev_role]), - ) + "bot author and core-dev reactor", + ), ) for emoji in self.syncer._REACTION_EMOJIS: - for author, user in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji): + for author, user, msg in user_subtests: + with self.subTest(author=author, user=user, emoji=emoji, msg=msg): message, reaction = self.get_message_reaction(emoji) ret_val = self.syncer._reaction_check(author, message, reaction, user) -- cgit v1.2.3 From 745c9d15114f90d01f8c21e30c2c40335c199a9e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jan 2020 10:18:18 -0800 Subject: Tests: add a return value for MockReaction.__str__ --- tests/helpers.py | 1 + 1 file changed, 1 insertion(+) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 71b80a223..b18a27ebe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -521,6 +521,7 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) self.users = AsyncIteratorMock(kwargs.get('users', [])) + self.__str__.return_value = str(self.emoji) webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) -- cgit v1.2.3 From 792e7d4bc71ffd7aa6087097b8276a6833c28b90 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jan 2020 10:27:02 -0800 Subject: Sync tests: test _wait_for_confirmation The message should always be edited and only return True if the emoji is a check mark. --- tests/bot/cogs/sync/test_base.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 2d682faad..d9f9c6d98 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -205,3 +205,41 @@ class SyncerConfirmationTests(unittest.TestCase): with self.subTest(**kwargs, msg=msg): ret_val = self.syncer._reaction_check(*args) self.assertFalse(ret_val) + + def test_wait_for_confirmation(self): + """The message should always be edited and only return True if the emoji is a check mark.""" + subtests = ( + (constants.Emojis.check_mark, True, None), + ("InVaLiD", False, None), + (None, False, TimeoutError), + ) + + for emoji, ret_val, side_effect in subtests: + for bot in (True, False): + with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): + # Set up mocks + message = helpers.MockMessage() + member = helpers.MockMember(bot=bot) + + self.bot.wait_for.reset_mock() + self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) + self.bot.wait_for.side_effect = side_effect + + # Call the function + actual_return = asyncio.run(self.syncer._wait_for_confirmation(member, message)) + + # Perform assertions + self.bot.wait_for.assert_called_once() + self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) + + message.edit.assert_called_once() + kwargs = message.edit.call_args[1] + self.assertIn("content", kwargs) + + # Core devs should only be mentioned if the author is a bot. + if bot: + self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + else: + self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) + + self.assertIs(actual_return, ret_val) -- cgit v1.2.3 From 555d1f47d75afbaaae2758fac8460d8d6af65d61 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 15 Jan 2020 10:53:38 -0800 Subject: Sync tests: test sync with an empty diff A confirmation prompt should not be sent if the diff is too small. --- tests/bot/cogs/sync/test_base.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index d9f9c6d98..642be75eb 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -5,7 +5,7 @@ from unittest import mock import discord from bot import constants -from bot.cogs.sync.syncers import Syncer +from bot.cogs.sync.syncers import Syncer, _Diff from tests import helpers @@ -243,3 +243,27 @@ class SyncerConfirmationTests(unittest.TestCase): self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) self.assertIs(actual_return, ret_val) + + +class SyncerSyncTests(unittest.TestCase): + """Tests for main function orchestrating the sync.""" + + def setUp(self): + self.bot = helpers.MockBot() + self.syncer = TestSyncer(self.bot) + + def test_sync_with_empty_diff(self): + """A confirmation prompt should not be sent if the diff is too small.""" + guild = helpers.MockGuild() + diff = _Diff(set(), set(), set()) + + self.syncer._send_prompt = helpers.AsyncMock() + self.syncer._wait_for_confirmation = helpers.AsyncMock() + self.syncer._get_diff.return_value = diff + + asyncio.run(self.syncer.sync(guild)) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._send_prompt.assert_not_called() + self.syncer._wait_for_confirmation.assert_not_called() + self.syncer._sync.assert_called_once_with(diff) -- cgit v1.2.3 From a7ba405732e28e8c44e7ddedce8136f6319980b0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jan 2020 10:43:51 -0800 Subject: Sync tests: test sync sends a confirmation prompt The prompt should be sent only if the diff is large and should fail if not confirmed. The empty diff test was integrated into this new test. --- tests/bot/cogs/sync/test_base.py | 48 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 642be75eb..898b12b07 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -252,18 +252,42 @@ class SyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - def test_sync_with_empty_diff(self): - """A confirmation prompt should not be sent if the diff is too small.""" - guild = helpers.MockGuild() - diff = _Diff(set(), set(), set()) + def test_sync_sends_confirmation_prompt(self): + """The prompt should be sent only if the diff is large and should fail if not confirmed.""" + large_diff = _Diff({1}, {2}, {3}) + subtests = ( + (False, False, True, None, None, _Diff({1}, {2}, set()), "diff too small"), + (True, True, True, helpers.MockMessage(), True, large_diff, "confirmed"), + (True, False, False, None, None, large_diff, "couldn't get channel"), + (True, True, False, helpers.MockMessage(), False, large_diff, "not confirmed"), + ) + + for prompt_called, wait_called, sync_called, prompt_msg, confirmed, diff, msg in subtests: + with self.subTest(msg=msg): + self.syncer._sync.reset_mock() + self.syncer._get_diff.reset_mock() + + self.syncer.MAX_DIFF = 2 + self.syncer._get_diff.return_value = diff + self.syncer._send_prompt = helpers.AsyncMock(return_value=prompt_msg) + self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild)) + + self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._send_prompt = helpers.AsyncMock() - self.syncer._wait_for_confirmation = helpers.AsyncMock() - self.syncer._get_diff.return_value = diff + if prompt_called: + self.syncer._send_prompt.assert_called_once() + else: + self.syncer._send_prompt.assert_not_called() - asyncio.run(self.syncer.sync(guild)) + if wait_called: + self.syncer._wait_for_confirmation.assert_called_once() + else: + self.syncer._wait_for_confirmation.assert_not_called() - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - self.syncer._sync.assert_called_once_with(diff) + if sync_called: + self.syncer._sync.assert_called_once_with(diff) + else: + self.syncer._sync.assert_not_called() -- cgit v1.2.3 From 08ad97d24590882dbb6a5575b6a3e7bfdbf145a3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jan 2020 09:18:18 -0800 Subject: Sync tests: adjust sync test to account for _get_confirmation_result --- tests/bot/cogs/sync/test_base.py | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 898b12b07..f82984157 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -252,42 +252,32 @@ class SyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - def test_sync_sends_confirmation_prompt(self): - """The prompt should be sent only if the diff is large and should fail if not confirmed.""" - large_diff = _Diff({1}, {2}, {3}) + def test_sync_respects_confirmation_result(self): + """The sync should abort if confirmation fails and continue if confirmed.""" + mock_message = helpers.MockMessage() subtests = ( - (False, False, True, None, None, _Diff({1}, {2}, set()), "diff too small"), - (True, True, True, helpers.MockMessage(), True, large_diff, "confirmed"), - (True, False, False, None, None, large_diff, "couldn't get channel"), - (True, True, False, helpers.MockMessage(), False, large_diff, "not confirmed"), + (True, mock_message), + (False, None), ) - for prompt_called, wait_called, sync_called, prompt_msg, confirmed, diff, msg in subtests: - with self.subTest(msg=msg): + for confirmed, message in subtests: + with self.subTest(confirmed=confirmed): self.syncer._sync.reset_mock() self.syncer._get_diff.reset_mock() - self.syncer.MAX_DIFF = 2 + diff = _Diff({1, 2, 3}, {4, 5}, None) self.syncer._get_diff.return_value = diff - self.syncer._send_prompt = helpers.AsyncMock(return_value=prompt_msg) - self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + self.syncer._get_confirmation_result = helpers.AsyncMock( + return_value=(confirmed, message) + ) guild = helpers.MockGuild() asyncio.run(self.syncer.sync(guild)) self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() - if prompt_called: - self.syncer._send_prompt.assert_called_once() - else: - self.syncer._send_prompt.assert_not_called() - - if wait_called: - self.syncer._wait_for_confirmation.assert_called_once() - else: - self.syncer._wait_for_confirmation.assert_not_called() - - if sync_called: + if confirmed: self.syncer._sync.assert_called_once_with(diff) else: self.syncer._sync.assert_not_called() -- cgit v1.2.3 From 8fab4db24939d6d7dd9256c0faf13395e7caddb7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jan 2020 09:33:40 -0800 Subject: Sync tests: test diff size calculation --- tests/bot/cogs/sync/test_base.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index f82984157..6d784d0de 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -281,3 +281,25 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._sync.assert_called_once_with(diff) else: self.syncer._sync.assert_not_called() + + def test_sync_diff_size(self): + """The diff size should be correctly calculated.""" + subtests = ( + (6, _Diff({1, 2}, {3, 4}, {5, 6})), + (5, _Diff({1, 2, 3}, None, {4, 5})), + (0, _Diff(None, None, None)), + (0, _Diff(set(), set(), set())), + ) + + for size, diff in subtests: + with self.subTest(size=size, diff=diff): + self.syncer._get_diff.reset_mock() + self.syncer._get_diff.return_value = diff + self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild)) + + self.syncer._get_diff.assert_called_once_with(guild) + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) -- cgit v1.2.3 From 7692d506454d5aa125135eac17ed291cc160ef2b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Jan 2020 09:24:46 -0800 Subject: Sync tests: test sync edits the message if one was sent --- tests/bot/cogs/sync/test_base.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 6d784d0de..ae8e53ffa 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -303,3 +303,18 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_diff.assert_called_once_with(guild) self.syncer._get_confirmation_result.assert_called_once() self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) + + def test_sync_message_edited(self): + """The message should be edited if one was sent.""" + for message in (helpers.MockMessage(), None): + with self.subTest(message=message): + self.syncer._get_confirmation_result = helpers.AsyncMock( + return_value=(True, message) + ) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild)) + + if message is not None: + message.edit.assert_called_once() + self.assertIn("content", message.edit.call_args[1]) -- cgit v1.2.3 From bf22311fb844c7122f2af9b3a51d9c25382fc452 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 21 Jan 2020 17:00:09 -0800 Subject: Sync tests: test sync passes correct author for confirmation Author should be the bot or the ctx author, if a ctx is given. --- tests/bot/cogs/sync/test_base.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ae8e53ffa..dfc8320d2 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -249,7 +249,7 @@ class SyncerSyncTests(unittest.TestCase): """Tests for main function orchestrating the sync.""" def setUp(self): - self.bot = helpers.MockBot() + self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) def test_sync_respects_confirmation_result(self): @@ -318,3 +318,21 @@ class SyncerSyncTests(unittest.TestCase): if message is not None: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) + + def test_sync_confirmation_author(self): + """Author should be the bot or the ctx author, if a ctx is given.""" + mock_member = helpers.MockMember() + subtests = ( + (None, self.bot.user), + (helpers.MockContext(author=mock_member), mock_member), + ) + + for ctx, author in subtests: + with self.subTest(ctx=ctx, author=author): + self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + + guild = helpers.MockGuild() + asyncio.run(self.syncer.sync(guild, ctx)) + + self.syncer._get_confirmation_result.assert_called_once() + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) -- cgit v1.2.3 From eaf44846fd8eaee3f52ca1d8b2f146655298b488 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Jan 2020 18:07:29 -0800 Subject: Sync tests: test sync redirects confirmation message to given context If ctx is given, a new message should be sent and author should be ctx's author. test_sync_confirmation_author was re-worked to include a test for the message being sent and passed. --- tests/bot/cogs/sync/test_base.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index dfc8320d2..a2df3e24e 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -319,20 +319,27 @@ class SyncerSyncTests(unittest.TestCase): message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - def test_sync_confirmation_author(self): - """Author should be the bot or the ctx author, if a ctx is given.""" + def test_sync_confirmation_context_redirect(self): + """If ctx is given, a new message should be sent and author should be ctx's author.""" mock_member = helpers.MockMember() subtests = ( - (None, self.bot.user), - (helpers.MockContext(author=mock_member), mock_member), + (None, self.bot.user, None), + (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), ) - for ctx, author in subtests: - with self.subTest(ctx=ctx, author=author): + for ctx, author, message in subtests: + with self.subTest(ctx=ctx, author=author, message=message): + if ctx is not None: + ctx.send.return_value = message + self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() asyncio.run(self.syncer.sync(guild, ctx)) + if ctx is not None: + ctx.send.assert_called_once() + self.syncer._get_confirmation_result.assert_called_once() self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) + self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) -- cgit v1.2.3 From 879ada59bf0a17f5cbf2590a7eb2426825b3635e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 23 Jan 2020 13:35:36 -0800 Subject: Sync tests: test sync edits message even if there's an API error --- tests/bot/cogs/sync/test_base.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index a2df3e24e..314f8a70c 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -5,6 +5,7 @@ from unittest import mock import discord from bot import constants +from bot.api import ResponseCodeError from bot.cogs.sync.syncers import Syncer, _Diff from tests import helpers @@ -305,9 +306,16 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) def test_sync_message_edited(self): - """The message should be edited if one was sent.""" - for message in (helpers.MockMessage(), None): - with self.subTest(message=message): + """The message should be edited if one was sent, even if the sync has an API error.""" + subtests = ( + (None, None, False), + (helpers.MockMessage(), None, True), + (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True), + ) + + for message, side_effect, should_edit in subtests: + with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): + self.syncer._sync.side_effect = side_effect self.syncer._get_confirmation_result = helpers.AsyncMock( return_value=(True, message) ) @@ -315,7 +323,7 @@ class SyncerSyncTests(unittest.TestCase): guild = helpers.MockGuild() asyncio.run(self.syncer.sync(guild)) - if message is not None: + if should_edit: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) -- cgit v1.2.3 From dfd4ca2bf4d4b8717a648d3f291cc3daeeb762d4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 24 Jan 2020 18:32:10 -0800 Subject: Sync tests: test _get_confirmation_result for small diffs Should always return True and the given message if the diff size is too small. --- tests/bot/cogs/sync/test_base.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 314f8a70c..21f14f89a 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -351,3 +351,22 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result.assert_called_once() self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) + + def test_confirmation_result_small_diff(self): + """Should always return True and the given message if the diff size is too small.""" + self.syncer.MAX_DIFF = 3 + author = helpers.MockMember() + expected_message = helpers.MockMessage() + + for size in (3, 2): + with self.subTest(size=size): + self.syncer._send_prompt = helpers.AsyncMock() + self.syncer._wait_for_confirmation = helpers.AsyncMock() + + coro = self.syncer._get_confirmation_result(size, author, expected_message) + result, actual_message = asyncio.run(coro) + + self.assertTrue(result) + self.assertEqual(actual_message, expected_message) + self.syncer._send_prompt.assert_not_called() + self.syncer._wait_for_confirmation.assert_not_called() -- cgit v1.2.3 From 4385422fc0f64cb592a9bb1d5815cc91a0ca09a0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 24 Jan 2020 18:48:40 -0800 Subject: Sync tests: test _get_confirmation_result for large diffs Should return True if confirmed and False if _send_prompt fails or aborted. --- tests/bot/cogs/sync/test_base.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 21f14f89a..ff11d911e 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -370,3 +370,32 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(actual_message, expected_message) self.syncer._send_prompt.assert_not_called() self.syncer._wait_for_confirmation.assert_not_called() + + def test_confirmation_result_large_diff(self): + """Should return True if confirmed and False if _send_prompt fails or aborted.""" + self.syncer.MAX_DIFF = 3 + author = helpers.MockMember() + mock_message = helpers.MockMessage() + + subtests = ( + (True, mock_message, True, "confirmed"), + (False, None, False, "_send_prompt failed"), + (False, mock_message, False, "aborted"), + ) + + for expected_result, expected_message, confirmed, msg in subtests: + with self.subTest(msg=msg): + self.syncer._send_prompt = helpers.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + + coro = self.syncer._get_confirmation_result(4, author) + actual_result, actual_message = asyncio.run(coro) + + self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None + self.assertIs(actual_result, expected_result) + self.assertEqual(actual_message, expected_message) + + if expected_message: + self.syncer._wait_for_confirmation.assert_called_once_with( + author, expected_message + ) -- cgit v1.2.3 From 69f59078394193f615753b0a20d74982e58d5c0f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 26 Jan 2020 18:27:06 -0800 Subject: Sync tests: test the extension setup The Sync cog should be added. --- tests/bot/cogs/sync/test_cog.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/bot/cogs/sync/test_cog.py (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py new file mode 100644 index 000000000..fb0f044b0 --- /dev/null +++ b/tests/bot/cogs/sync/test_cog.py @@ -0,0 +1,15 @@ +import unittest + +from bot.cogs import sync +from tests import helpers + + +class SyncExtensionTests(unittest.TestCase): + """Tests for the sync extension.""" + + @staticmethod + def test_extension_setup(): + """The Sync cog should be added.""" + bot = helpers.MockBot() + sync.setup(bot) + bot.add_cog.assert_called_once() -- cgit v1.2.3 From 32048b12d98d3b04a336ae53e12b81681a51e72a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 27 Jan 2020 22:00:11 -0800 Subject: Sync tests: test Sync cog __init__ Should instantiate syncers and run a sync for the guild. --- tests/bot/cogs/sync/test_cog.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index fb0f044b0..efffaf53b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock from bot.cogs import sync from tests import helpers @@ -13,3 +14,23 @@ class SyncExtensionTests(unittest.TestCase): bot = helpers.MockBot() sync.setup(bot) bot.add_cog.assert_called_once() + + +class SyncCogTests(unittest.TestCase): + """Tests for the Sync cog.""" + + def setUp(self): + self.bot = helpers.MockBot() + + @mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) + @mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) + def test_sync_cog_init(self, mock_role, mock_sync): + """Should instantiate syncers and run a sync for the guild.""" + mock_sync_guild_coro = mock.MagicMock() + sync.Sync.sync_guild = mock.MagicMock(return_value=mock_sync_guild_coro) + + sync.Sync(self.bot) + + mock_role.assert_called_once_with(self.bot) + mock_sync.assert_called_once_with(self.bot) + self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) -- cgit v1.2.3 From f9e72150b5e2f4c2ae4b3968ef2d2da29fd5adbd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 28 Jan 2020 18:31:06 -0800 Subject: Sync tests: instantiate a Sync cog in setUp * Move patches to setUp --- tests/bot/cogs/sync/test_cog.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index efffaf53b..74afa2f9d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -22,15 +22,29 @@ class SyncCogTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() - @mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) - @mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) - def test_sync_cog_init(self, mock_role, mock_sync): + self.role_syncer_patcher = mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) + self.user_syncer_patcher = mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) + self.RoleSyncer = self.role_syncer_patcher.start() + self.UserSyncer = self.user_syncer_patcher.start() + + self.cog = sync.Sync(self.bot) + + def tearDown(self): + self.role_syncer_patcher.stop() + self.user_syncer_patcher.stop() + + def test_sync_cog_init(self): """Should instantiate syncers and run a sync for the guild.""" + # Reset because a Sync cog was already instantiated in setUp. + self.RoleSyncer.reset_mock() + self.UserSyncer.reset_mock() + self.bot.loop.create_task.reset_mock() + mock_sync_guild_coro = mock.MagicMock() sync.Sync.sync_guild = mock.MagicMock(return_value=mock_sync_guild_coro) sync.Sync(self.bot) - mock_role.assert_called_once_with(self.bot) - mock_sync.assert_called_once_with(self.bot) + self.RoleSyncer.assert_called_once_with(self.bot) + self.UserSyncer.assert_called_once_with(self.bot) self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) -- cgit v1.2.3 From f1502c6cc6c65be5b2b29066c8a2d774e73935d9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Jan 2020 17:00:55 -0800 Subject: Sync tests: use mock.patch for sync_guild This prevents persistence of changes to the cog instance; sync_guild would otherwise remain as a mock object for any subsequent tests. --- tests/bot/cogs/sync/test_cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 74afa2f9d..118782db3 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -33,7 +33,8 @@ class SyncCogTests(unittest.TestCase): self.role_syncer_patcher.stop() self.user_syncer_patcher.stop() - def test_sync_cog_init(self): + @mock.patch.object(sync.Sync, "sync_guild") + def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" # Reset because a Sync cog was already instantiated in setUp. self.RoleSyncer.reset_mock() @@ -41,10 +42,11 @@ class SyncCogTests(unittest.TestCase): self.bot.loop.create_task.reset_mock() mock_sync_guild_coro = mock.MagicMock() - sync.Sync.sync_guild = mock.MagicMock(return_value=mock_sync_guild_coro) + sync_guild.return_value = mock_sync_guild_coro sync.Sync(self.bot) self.RoleSyncer.assert_called_once_with(self.bot) self.UserSyncer.assert_called_once_with(self.bot) + sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) -- cgit v1.2.3 From bd5980728bd7bfd5bba53369934698c43f12fa05 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Jan 2020 17:09:43 -0800 Subject: Sync tests: fix Syncer mocks not having async methods While on 3.7, the CustomMockMixin needs to be leveraged so that coroutine members are replace with AsyncMocks instead. --- tests/bot/cogs/sync/test_cog.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 118782db3..ec66c795d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -2,9 +2,21 @@ import unittest from unittest import mock from bot.cogs import sync +from bot.cogs.sync.syncers import Syncer from tests import helpers +class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): + """ + A MagicMock subclass to mock Syncer objects. + + Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` + instances. For more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec_set=Syncer, **kwargs) + + class SyncExtensionTests(unittest.TestCase): """Tests for the sync extension.""" @@ -22,8 +34,17 @@ class SyncCogTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() - self.role_syncer_patcher = mock.patch("bot.cogs.sync.syncers.RoleSyncer", autospec=True) - self.user_syncer_patcher = mock.patch("bot.cogs.sync.syncers.UserSyncer", autospec=True) + # These patch the type. When the type is called, a MockSyncer instanced is returned. + # MockSyncer is needed so that our custom AsyncMock is used. + # TODO: Use autospec instead in 3.8, which will automatically use AsyncMock when needed. + self.role_syncer_patcher = mock.patch( + "bot.cogs.sync.syncers.RoleSyncer", + new=mock.MagicMock(return_value=MockSyncer()) + ) + self.user_syncer_patcher = mock.patch( + "bot.cogs.sync.syncers.UserSyncer", + new=mock.MagicMock(return_value=MockSyncer()) + ) self.RoleSyncer = self.role_syncer_patcher.start() self.UserSyncer = self.user_syncer_patcher.start() -- cgit v1.2.3 From 607e4480badd58d5de36d5be3306498afcb4348c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Jan 2020 18:47:58 -0800 Subject: Sync tests: test sync_guild Roles and users should be synced only if a guild is successfully retrieved. --- tests/bot/cogs/sync/test_cog.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index ec66c795d..09ce0ae16 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,6 +1,8 @@ +import asyncio import unittest from unittest import mock +from bot import constants from bot.cogs import sync from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -71,3 +73,25 @@ class SyncCogTests(unittest.TestCase): self.UserSyncer.assert_called_once_with(self.bot) sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) + + def test_sync_cog_sync_guild(self): + """Roles and users should be synced only if a guild is successfully retrieved.""" + for guild in (helpers.MockGuild(), None): + with self.subTest(guild=guild): + self.bot.reset_mock() + self.cog.role_syncer.reset_mock() + self.cog.user_syncer.reset_mock() + + self.bot.get_guild = mock.MagicMock(return_value=guild) + + asyncio.run(self.cog.sync_guild()) + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(constants.Guild.id) + + if guild is None: + self.cog.role_syncer.sync.assert_not_called() + self.cog.user_syncer.sync.assert_not_called() + else: + self.cog.role_syncer.sync.assert_called_once_with(guild) + self.cog.user_syncer.sync.assert_called_once_with(guild) -- cgit v1.2.3 From a0253c2349bead625633737964ba4203d75db7aa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 11:21:19 -0800 Subject: Sync tests: test patch_user A PATCH request should be sent. The error should only be raised if it is not a 404. * Add a fixture to create ResponseCodeErrors with a specific status --- tests/bot/cogs/sync/test_cog.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 09ce0ae16..0eb8954f1 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -3,6 +3,7 @@ import unittest from unittest import mock from bot import constants +from bot.api import ResponseCodeError from bot.cogs import sync from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -56,6 +57,14 @@ class SyncCogTests(unittest.TestCase): self.role_syncer_patcher.stop() self.user_syncer_patcher.stop() + @staticmethod + def response_error(status: int) -> ResponseCodeError: + """Fixture to return a ResponseCodeError with the given status code.""" + response = mock.MagicMock() + response.status = status + + return ResponseCodeError(response) + @mock.patch.object(sync.Sync, "sync_guild") def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" @@ -95,3 +104,20 @@ class SyncCogTests(unittest.TestCase): else: self.cog.role_syncer.sync.assert_called_once_with(guild) self.cog.user_syncer.sync.assert_called_once_with(guild) + + def test_sync_cog_patch_user(self): + """A PATCH request should be sent and 404 errors ignored.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + self.bot.api_client.patch.reset_mock(side_effect=True) + self.bot.api_client.patch.side_effect = side_effect + + asyncio.run(self.cog.patch_user(5, {})) + self.bot.api_client.patch.assert_called_once() + + def test_sync_cog_patch_user_non_404(self): + """A PATCH request should be sent and the error raised if it's not a 404.""" + self.bot.api_client.patch.side_effect = self.response_error(500) + with self.assertRaises(ResponseCodeError): + asyncio.run(self.cog.patch_user(5, {})) + self.bot.api_client.patch.assert_called_once() -- cgit v1.2.3 From 93b3ec43526096bdf3f4c8a9ee2c9de29d25a562 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 12:52:40 -0800 Subject: Sync tests: add helper function for testing patch_user Reduces redundancy in the tests by taking care of the mocks, calling of the function, and the assertion. --- tests/bot/cogs/sync/test_cog.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 0eb8954f1..bdb7aeb63 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -105,19 +105,26 @@ class SyncCogTests(unittest.TestCase): self.cog.role_syncer.sync.assert_called_once_with(guild) self.cog.user_syncer.sync.assert_called_once_with(guild) + def patch_user_helper(self, side_effect: BaseException) -> None: + """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" + self.bot.api_client.patch.reset_mock(side_effect=True) + self.bot.api_client.patch.side_effect = side_effect + + user_id, updated_information = 5, {"key": 123} + asyncio.run(self.cog.patch_user(user_id, updated_information)) + + self.bot.api_client.patch.assert_called_once_with( + f"bot/users/{user_id}", + json=updated_information, + ) + def test_sync_cog_patch_user(self): """A PATCH request should be sent and 404 errors ignored.""" for side_effect in (None, self.response_error(404)): with self.subTest(side_effect=side_effect): - self.bot.api_client.patch.reset_mock(side_effect=True) - self.bot.api_client.patch.side_effect = side_effect - - asyncio.run(self.cog.patch_user(5, {})) - self.bot.api_client.patch.assert_called_once() + self.patch_user_helper(side_effect) def test_sync_cog_patch_user_non_404(self): """A PATCH request should be sent and the error raised if it's not a 404.""" - self.bot.api_client.patch.side_effect = self.response_error(500) with self.assertRaises(ResponseCodeError): - asyncio.run(self.cog.patch_user(5, {})) - self.bot.api_client.patch.assert_called_once() + self.patch_user_helper(self.response_error(500)) -- cgit v1.2.3 From 097a5231067320b73277852202444c404bb0adbb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 13:39:19 -0800 Subject: Sync tests: create a base TestCase class for Sync cog tests --- tests/bot/cogs/sync/test_cog.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index bdb7aeb63..c6009b2e5 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -31,8 +31,8 @@ class SyncExtensionTests(unittest.TestCase): bot.add_cog.assert_called_once() -class SyncCogTests(unittest.TestCase): - """Tests for the Sync cog.""" +class SyncCogTestCase(unittest.TestCase): + """Base class for Sync cog tests. Sets up patches for syncers.""" def setUp(self): self.bot = helpers.MockBot() @@ -65,6 +65,10 @@ class SyncCogTests(unittest.TestCase): return ResponseCodeError(response) + +class SyncCogTests(SyncCogTestCase): + """Tests for the Sync cog.""" + @mock.patch.object(sync.Sync, "sync_guild") def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" -- cgit v1.2.3 From 3c0937de8641092100acc6424f4455c49d2e7855 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 13:54:20 -0800 Subject: Sync tests: create a test case for listener tests --- tests/bot/cogs/sync/test_cog.py | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index c6009b2e5..d71366791 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -132,3 +132,10 @@ class SyncCogTests(SyncCogTestCase): """A PATCH request should be sent and the error raised if it's not a 404.""" with self.assertRaises(ResponseCodeError): self.patch_user_helper(self.response_error(500)) + + +class SyncCogListenerTests(SyncCogTestCase): + """Tests for the listeners of the Sync cog.""" + def setUp(self): + super().setUp() + self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) -- cgit v1.2.3 From 948661e3738ae2bd2636631bf2a91c1589aa0bde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 14:04:17 -0800 Subject: Sync tests: test Sync cog's on_guild_role_create listener A POST request should be sent with the new role's data. * Add a fixture to create a MockRole --- tests/bot/cogs/sync/test_cog.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index d71366791..a4969551d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,7 +1,10 @@ import asyncio +import typing as t import unittest from unittest import mock +import discord + from bot import constants from bot.api import ResponseCodeError from bot.cogs import sync @@ -139,3 +142,29 @@ class SyncCogListenerTests(SyncCogTestCase): def setUp(self): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) + + @staticmethod + def mock_role() -> t.Tuple[helpers.MockRole, t.Dict[str, t.Any]]: + """Fixture to return a MockRole and corresponding JSON dict.""" + colour = 49 + permissions = 8 + role_data = { + "colour": colour, + "id": 777, + "name": "rolename", + "permissions": permissions, + "position": 23, + } + + role = helpers.MockRole(**role_data) + role.colour = discord.Colour(colour) + role.permissions = discord.Permissions(permissions) + + return role, role_data + + def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" + role, role_data = self.mock_role() + asyncio.run(self.cog.on_guild_role_create(role)) + + self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) -- cgit v1.2.3 From d249d4517cbd903a550047bd91e9c83bf828b9d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jan 2020 14:10:52 -0800 Subject: Sync tests: test Sync cog's on_guild_role_delete listener A DELETE request should be sent. --- tests/bot/cogs/sync/test_cog.py | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index a4969551d..e183b429f 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -168,3 +168,10 @@ class SyncCogListenerTests(SyncCogTestCase): asyncio.run(self.cog.on_guild_role_create(role)) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) + + def test_sync_cog_on_guild_role_delete(self): + """A DELETE request should be sent.""" + role = helpers.MockRole(id=99) + asyncio.run(self.cog.on_guild_role_delete(role)) + + self.bot.api_client.delete.assert_called_once_with("bot/roles/99") -- cgit v1.2.3 From 535095ff647277922b7d1930da8d038f15af74fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Feb 2020 15:32:16 -0800 Subject: Tests: use objects for colour and permissions of MockRole Instances of discord.Colour and discord.Permissions will be created by default or when ints are given as values for those attributes. --- tests/helpers.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index b18a27ebe..a40673bb9 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -270,9 +270,21 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ def __init__(self, **kwargs) -> None: - default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} + default_kwargs = { + 'id': next(self.discord_id), + 'name': 'role', + 'position': 1, + 'colour': discord.Colour(0xdeadbf), + 'permissions': discord.Permissions(), + } super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) + if isinstance(self.colour, int): + self.colour = discord.Colour(self.colour) + + if isinstance(self.permissions, int): + self.permissions = discord.Permissions(self.permissions) + if 'mention' not in kwargs: self.mention = f'&{self.name}' -- cgit v1.2.3 From 4e81281ecc87a6d2af320b3c000aea286a50f2a7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Feb 2020 20:58:15 -0800 Subject: Sync tests: remove mock_role fixture It is obsolete because MockRole now takes care of creating the Colour and Permissions objects. --- tests/bot/cogs/sync/test_cog.py | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index e183b429f..604daa437 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,10 +1,7 @@ import asyncio -import typing as t import unittest from unittest import mock -import discord - from bot import constants from bot.api import ResponseCodeError from bot.cogs import sync @@ -143,28 +140,16 @@ class SyncCogListenerTests(SyncCogTestCase): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) - @staticmethod - def mock_role() -> t.Tuple[helpers.MockRole, t.Dict[str, t.Any]]: - """Fixture to return a MockRole and corresponding JSON dict.""" - colour = 49 - permissions = 8 + def test_sync_cog_on_guild_role_create(self): + """A POST request should be sent with the new role's data.""" role_data = { - "colour": colour, + "colour": 49, "id": 777, "name": "rolename", - "permissions": permissions, + "permissions": 8, "position": 23, } - role = helpers.MockRole(**role_data) - role.colour = discord.Colour(colour) - role.permissions = discord.Permissions(permissions) - - return role, role_data - - def test_sync_cog_on_guild_role_create(self): - """A POST request should be sent with the new role's data.""" - role, role_data = self.mock_role() asyncio.run(self.cog.on_guild_role_create(role)) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) -- cgit v1.2.3 From ad53b51b860858cb9434435de3d205165b2d78f8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Feb 2020 21:35:33 -0800 Subject: Sync tests: test Sync cog's on_guild_role_update A PUT request should be sent if the colour, name, permissions, or position changes. --- tests/bot/cogs/sync/test_cog.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 604daa437..9a3232b3a 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -160,3 +160,38 @@ class SyncCogListenerTests(SyncCogTestCase): asyncio.run(self.cog.on_guild_role_delete(role)) self.bot.api_client.delete.assert_called_once_with("bot/roles/99") + + def test_sync_cog_on_guild_role_update(self): + """A PUT request should be sent if the colour, name, permissions, or position changes.""" + role_data = { + "colour": 49, + "id": 777, + "name": "rolename", + "permissions": 8, + "position": 23, + } + subtests = ( + (True, ("colour", "name", "permissions", "position")), + (False, ("hoist", "mentionable")), + ) + + for should_put, attributes in subtests: + for attribute in attributes: + with self.subTest(should_put=should_put, changed_attribute=attribute): + self.bot.api_client.put.reset_mock() + + after_role_data = role_data.copy() + after_role_data[attribute] = 876 + + before_role = helpers.MockRole(**role_data) + after_role = helpers.MockRole(**after_role_data) + + asyncio.run(self.cog.on_guild_role_update(before_role, after_role)) + + if should_put: + self.bot.api_client.put.assert_called_once_with( + f"bot/roles/{after_role.id}", + json=after_role_data + ) + else: + self.bot.api_client.put.assert_not_called() -- cgit v1.2.3 From 524026576d89cf84d0e44b3cb36ee8810e924396 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Feb 2020 21:07:52 -0800 Subject: Sync tests: test Sync cog's on_member_remove A PUT request should be sent to set in_guild as False and update other fields. --- tests/bot/cogs/sync/test_cog.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 9a3232b3a..4ee66a518 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -195,3 +195,20 @@ class SyncCogListenerTests(SyncCogTestCase): ) else: self.bot.api_client.put.assert_not_called() + + def test_sync_cog_on_member_remove(self): + """A PUT request should be sent to set in_guild as False and update other fields.""" + roles = [helpers.MockRole(id=i) for i in (57, 22, 43)] # purposefully unsorted + member = helpers.MockMember(roles=roles) + + asyncio.run(self.cog.on_member_remove(member)) + + json_data = { + "avatar_hash": member.avatar, + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": False, + "name": member.name, + "roles": sorted(role.id for role in member.roles) + } + self.bot.api_client.put.assert_called_once_with("bot/users/88", json=json_data) -- cgit v1.2.3 From 7748de87d507d2732c58a77ae6300b8c925fa8c9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 11:34:01 -0800 Subject: Sync tests: test Sync cog's on_member_update for roles Members should be patched if their roles have changed. --- tests/bot/cogs/sync/test_cog.py | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 4ee66a518..f04d53caa 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -212,3 +212,14 @@ class SyncCogListenerTests(SyncCogTestCase): "roles": sorted(role.id for role in member.roles) } self.bot.api_client.put.assert_called_once_with("bot/users/88", json=json_data) + + def test_sync_cog_on_member_update_roles(self): + """Members should be patched if their roles have changed.""" + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30)] + before_member = helpers.MockMember(roles=before_roles) + after_member = helpers.MockMember(roles=before_roles[1:]) + + asyncio.run(self.cog.on_member_update(before_member, after_member)) + + data = {"roles": sorted(role.id for role in after_member.roles)} + self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) -- cgit v1.2.3 From 562a33184b52525bc8f9cfda8aaeb8245087e135 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 11:37:20 -0800 Subject: Sync tests: test Sync cog's on_member_update for other attributes Members should not be patched if other attributes have changed. --- tests/bot/cogs/sync/test_cog.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f04d53caa..36945b82e 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -2,6 +2,8 @@ import asyncio import unittest from unittest import mock +import discord + from bot import constants from bot.api import ResponseCodeError from bot.cogs import sync @@ -223,3 +225,22 @@ class SyncCogListenerTests(SyncCogTestCase): data = {"roles": sorted(role.id for role in after_member.roles)} self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) + + def test_sync_cog_on_member_update_other(self): + """Members should not be patched if other attributes have changed.""" + subtests = ( + ("activities", discord.Game("Pong"), discord.Game("Frogger")), + ("nick", "old nick", "new nick"), + ("status", discord.Status.online, discord.Status.offline) + ) + + for attribute, old_value, new_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + before_member = helpers.MockMember(**{attribute: old_value}) + after_member = helpers.MockMember(**{attribute: new_value}) + + asyncio.run(self.cog.on_member_update(before_member, after_member)) + + self.cog.patch_user.assert_not_called() -- cgit v1.2.3 From df1e4f10b4ffc6a514528d03d10d3854385986ac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 12:08:23 -0800 Subject: Sync tests: fix ID in endpoint for test_sync_cog_on_member_remove --- tests/bot/cogs/sync/test_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 36945b82e..75165a5b2 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -213,7 +213,7 @@ class SyncCogListenerTests(SyncCogTestCase): "name": member.name, "roles": sorted(role.id for role in member.roles) } - self.bot.api_client.put.assert_called_once_with("bot/users/88", json=json_data) + self.bot.api_client.put.assert_called_once_with(f"bot/users/{member.id}", json=json_data) def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" -- cgit v1.2.3 From b3d19d72596052629f56823dfd6c63b42dda6253 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Feb 2020 13:14:33 -0800 Subject: Sync tests: test Sync cog's on_user_update A user should be patched only if the name, discriminator, or avatar changes. --- tests/bot/cogs/sync/test_cog.py | 43 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 75165a5b2..88c5e00b9 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -231,7 +231,7 @@ class SyncCogListenerTests(SyncCogTestCase): subtests = ( ("activities", discord.Game("Pong"), discord.Game("Frogger")), ("nick", "old nick", "new nick"), - ("status", discord.Status.online, discord.Status.offline) + ("status", discord.Status.online, discord.Status.offline), ) for attribute, old_value, new_value in subtests: @@ -244,3 +244,44 @@ class SyncCogListenerTests(SyncCogTestCase): asyncio.run(self.cog.on_member_update(before_member, after_member)) self.cog.patch_user.assert_not_called() + + def test_sync_cog_on_user_update(self): + """A user should be patched only if the name, discriminator, or avatar changes.""" + before_data = { + "name": "old name", + "discriminator": "1234", + "avatar": "old avatar", + "bot": False, + } + + subtests = ( + (True, "name", "name", "new name", "new name"), + (True, "discriminator", "discriminator", "8765", 8765), + (True, "avatar", "avatar_hash", "9j2e9", "9j2e9"), + (False, "bot", "bot", True, True), + ) + + for should_patch, attribute, api_field, value, api_value in subtests: + with self.subTest(attribute=attribute): + self.cog.patch_user.reset_mock() + + after_data = before_data.copy() + after_data[attribute] = value + before_user = helpers.MockUser(**before_data) + after_user = helpers.MockUser(**after_data) + + asyncio.run(self.cog.on_user_update(before_user, after_user)) + + if should_patch: + self.cog.patch_user.assert_called_once() + + # Don't care if *all* keys are present; only the changed one is required + call_args = self.cog.patch_user.call_args + self.assertEqual(call_args[0][0], after_user.id) + self.assertIn("updated_information", call_args[1]) + + updated_information = call_args[1]["updated_information"] + self.assertIn(api_field, updated_information) + self.assertEqual(updated_information[api_field], api_value) + else: + self.cog.patch_user.assert_not_called() -- cgit v1.2.3 From 5a685dfa2a99ee61a898940812b289cb9f448fdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 6 Feb 2020 13:01:25 -0800 Subject: Sync tests: test sync roles command sync() should be called on the RoleSyncer. --- tests/bot/cogs/sync/test_cog.py | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 88c5e00b9..4de058965 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -285,3 +285,12 @@ class SyncCogListenerTests(SyncCogTestCase): self.assertEqual(updated_information[api_field], api_value) else: self.cog.patch_user.assert_not_called() + + +class SyncCogCommandTests(SyncCogTestCase): + def test_sync_roles_command(self): + """sync() should be called on the RoleSyncer.""" + ctx = helpers.MockContext() + asyncio.run(self.cog.sync_roles_command.callback(self.cog, ctx)) + + self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) -- cgit v1.2.3 From 0e7211e80c76973e781db3bbea82a54e6a9ebb1c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 6 Feb 2020 13:11:37 -0800 Subject: Sync tests: test sync users command sync() should be called on the UserSyncer. --- tests/bot/cogs/sync/test_cog.py | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 4de058965..f21d1574b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -294,3 +294,10 @@ class SyncCogCommandTests(SyncCogTestCase): asyncio.run(self.cog.sync_roles_command.callback(self.cog, ctx)) self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + def test_sync_users_command(self): + """sync() should be called on the UserSyncer.""" + ctx = helpers.MockContext() + asyncio.run(self.cog.sync_users_command.callback(self.cog, ctx)) + + self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) -- cgit v1.2.3 From 7b9e71fbb1364a416e5239b45434874fed9eb857 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 16:31:07 -0800 Subject: Tests: create TestCase subclass with a permissions check assertion The subclass will contain assertions that are useful for testing Discord commands. The currently included assertion tests that a command will raise a MissingPermissions exception if the author lacks permissions. --- tests/base.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) (limited to 'tests') diff --git a/tests/base.py b/tests/base.py index 029a249ed..88693f382 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,6 +1,12 @@ import logging import unittest from contextlib import contextmanager +from typing import Dict + +import discord +from discord.ext import commands + +from tests import helpers class _CaptureLogHandler(logging.Handler): @@ -65,3 +71,31 @@ class LoggingTestCase(unittest.TestCase): standard_message = self._truncateMessage(base_message, record_message) msg = self._formatMessage(msg, standard_message) self.fail(msg) + + +class CommandTestCase(unittest.TestCase): + """TestCase with additional assertions that are useful for testing Discord commands.""" + + @helpers.async_test + async def assertHasPermissionsCheck( + self, + cmd: commands.Command, + permissions: Dict[str, bool], + ) -> None: + """ + Test that `cmd` raises a `MissingPermissions` exception if author lacks `permissions`. + + Every permission in `permissions` is expected to be reported as missing. In other words, do + not include permissions which should not raise an exception along with those which should. + """ + # Invert permission values because it's more intuitive to pass to this assertion the same + # permissions as those given to the check decorator. + permissions = {k: not v for k, v in permissions.items()} + + ctx = helpers.MockContext() + ctx.channel.permissions_for.return_value = discord.Permissions(**permissions) + + with self.assertRaises(commands.MissingPermissions) as cm: + await cmd.can_run(ctx) + + self.assertCountEqual(permissions.keys(), cm.exception.missing_perms) -- cgit v1.2.3 From 2d0f25c2472b94e2b40fc12cc49fd2ad4272c9ee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 16:49:27 -0800 Subject: Sync tests: test sync commands require the admin permission The sync commands should only run if the author has the administrator permission. * Add missing spaces after class docstrings * Add missing docstring to SyncCogCommandTests --- tests/bot/cogs/sync/test_cog.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f21d1574b..b1f586a5b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -9,6 +9,7 @@ from bot.api import ResponseCodeError from bot.cogs import sync from bot.cogs.sync.syncers import Syncer from tests import helpers +from tests.base import CommandTestCase class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): @@ -18,6 +19,7 @@ class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: super().__init__(spec_set=Syncer, **kwargs) @@ -138,6 +140,7 @@ class SyncCogTests(SyncCogTestCase): class SyncCogListenerTests(SyncCogTestCase): """Tests for the listeners of the Sync cog.""" + def setUp(self): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) @@ -287,7 +290,9 @@ class SyncCogListenerTests(SyncCogTestCase): self.cog.patch_user.assert_not_called() -class SyncCogCommandTests(SyncCogTestCase): +class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): + """Tests for the commands in the Sync cog.""" + def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() @@ -301,3 +306,15 @@ class SyncCogCommandTests(SyncCogTestCase): asyncio.run(self.cog.sync_users_command.callback(self.cog, ctx)) self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) + + def test_commands_require_admin(self): + """The sync commands should only run if the author has the administrator permission.""" + cmds = ( + self.cog.sync_group, + self.cog.sync_roles_command, + self.cog.sync_users_command, + ) + + for cmd in cmds: + with self.subTest(cmd=cmd): + self.assertHasPermissionsCheck(cmd, {"administrator": True}) -- cgit v1.2.3 From e8b1fa52daf5950ad253e52c3b386a9d4967e739 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 17:02:02 -0800 Subject: Sync tests: assert that listeners are actually added as listeners --- tests/bot/cogs/sync/test_cog.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index b1f586a5b..f7e86f063 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -147,6 +147,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_guild_role_create(self): """A POST request should be sent with the new role's data.""" + self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) + role_data = { "colour": 49, "id": 777, @@ -161,6 +163,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_guild_role_delete(self): """A DELETE request should be sent.""" + self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) + role = helpers.MockRole(id=99) asyncio.run(self.cog.on_guild_role_delete(role)) @@ -168,6 +172,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_guild_role_update(self): """A PUT request should be sent if the colour, name, permissions, or position changes.""" + self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) + role_data = { "colour": 49, "id": 777, @@ -203,6 +209,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_member_remove(self): """A PUT request should be sent to set in_guild as False and update other fields.""" + self.assertTrue(self.cog.on_member_remove.__cog_listener__) + roles = [helpers.MockRole(id=i) for i in (57, 22, 43)] # purposefully unsorted member = helpers.MockMember(roles=roles) @@ -220,6 +228,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30)] before_member = helpers.MockMember(roles=before_roles) after_member = helpers.MockMember(roles=before_roles[1:]) @@ -231,6 +241,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_member_update_other(self): """Members should not be patched if other attributes have changed.""" + self.assertTrue(self.cog.on_member_update.__cog_listener__) + subtests = ( ("activities", discord.Game("Pong"), discord.Game("Frogger")), ("nick", "old nick", "new nick"), @@ -250,6 +262,8 @@ class SyncCogListenerTests(SyncCogTestCase): def test_sync_cog_on_user_update(self): """A user should be patched only if the name, discriminator, or avatar changes.""" + self.assertTrue(self.cog.on_user_update.__cog_listener__) + before_data = { "name": "old name", "discriminator": "1234", -- cgit v1.2.3 From 5c385da1a41b2a6463b38b1973e13fd4590d61cb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 10 Feb 2020 17:06:18 -0800 Subject: Sync tests: fix on_member_remove listener test The listener was changed earlier to simply set in_guild to False. This commit accounts for that in the test. --- tests/bot/cogs/sync/test_cog.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f7e86f063..a8c79e0d3 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -208,23 +208,16 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.put.assert_not_called() def test_sync_cog_on_member_remove(self): - """A PUT request should be sent to set in_guild as False and update other fields.""" + """Member should patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) - roles = [helpers.MockRole(id=i) for i in (57, 22, 43)] # purposefully unsorted - member = helpers.MockMember(roles=roles) - + member = helpers.MockMember() asyncio.run(self.cog.on_member_remove(member)) - json_data = { - "avatar_hash": member.avatar, - "discriminator": int(member.discriminator), - "id": member.id, - "in_guild": False, - "name": member.name, - "roles": sorted(role.id for role in member.roles) - } - self.bot.api_client.put.assert_called_once_with(f"bot/users/{member.id}", json=json_data) + self.cog.patch_user.assert_called_once_with( + member.id, + updated_information={"in_guild": False} + ) def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" -- cgit v1.2.3 From 03b885ac9f8e0d30d4c38ad0f18a1d391c94765b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 11 Feb 2020 09:56:43 -0800 Subject: Sync tests: add a third role with a lower ID to on_member_update test This better ensures that roles are being sorted when patching. --- tests/bot/cogs/sync/test_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index a8c79e0d3..88f6eb6cf 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -223,7 +223,8 @@ class SyncCogListenerTests(SyncCogTestCase): """Members should be patched if their roles have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) - before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30)] + # Roles are intentionally unsorted. + before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] before_member = helpers.MockMember(roles=before_roles) after_member = helpers.MockMember(roles=before_roles[1:]) -- cgit v1.2.3 From a1cb58ac1e784db64d82a082be25df3d524bfc20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Feb 2020 08:33:19 -0800 Subject: Sync tests: test on_member_join Should PUT user's data or POST it if the user doesn't exist. ResponseCodeError should be re-raised if status code isn't a 404. A helper method was added to reduce code redundancy between the 2 tests. --- tests/bot/cogs/sync/test_cog.py | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 88f6eb6cf..f66adfea1 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -297,6 +297,58 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.cog.patch_user.assert_not_called() + def on_member_join_helper(self, side_effect: Exception) -> dict: + """ + Helper to set `side_effect` for on_member_join and assert a PUT request was sent. + + The request data for the mock member is returned. All exceptions will be re-raised. + """ + member = helpers.MockMember( + discriminator="1234", + roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], + ) + + data = { + "avatar_hash": member.avatar, + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": True, + "name": member.name, + "roles": sorted(role.id for role in member.roles) + } + + self.bot.api_client.put.reset_mock(side_effect=True) + self.bot.api_client.put.side_effect = side_effect + + try: + asyncio.run(self.cog.on_member_join(member)) + except Exception: + raise + finally: + self.bot.api_client.put.assert_called_once_with( + f"bot/users/{member.id}", + json=data + ) + + return data + + def test_sync_cog_on_member_join(self): + """Should PUT user's data or POST it if the user doesn't exist.""" + for side_effect in (None, self.response_error(404)): + with self.subTest(side_effect=side_effect): + self.bot.api_client.post.reset_mock() + data = self.on_member_join_helper(side_effect) + + if side_effect: + self.bot.api_client.post.assert_called_once_with("bot/users", json=data) + else: + self.bot.api_client.post.assert_not_called() + + def test_sync_cog_on_member_join_non_404(self): + """ResponseCodeError should be re-raised if status code isn't a 404.""" + self.assertRaises(ResponseCodeError, self.on_member_join_helper, self.response_error(500)) + self.bot.api_client.post.assert_not_called() + class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" -- cgit v1.2.3 From b11e2eb365405dd63ac0fc3a830804b4b58e1ebc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Feb 2020 08:52:02 -0800 Subject: Sync tests: use async_test decorator --- tests/bot/cogs/sync/test_base.py | 61 +++++++++++++++++------------ tests/bot/cogs/sync/test_cog.py | 81 +++++++++++++++++++++++---------------- tests/bot/cogs/sync/test_roles.py | 41 ++++++++++++-------- tests/bot/cogs/sync/test_users.py | 46 +++++++++++++--------- 4 files changed, 135 insertions(+), 94 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index ff11d911e..0539f5683 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -62,16 +61,18 @@ class SyncerSendPromptTests(unittest.TestCase): return mock_channel, mock_message - def test_send_prompt_edits_and_returns_message(self): + @helpers.async_test + async def test_send_prompt_edits_and_returns_message(self): """The given message should be edited to display the prompt and then should be returned.""" msg = helpers.MockMessage() - ret_val = asyncio.run(self.syncer._send_prompt(msg)) + ret_val = await self.syncer._send_prompt(msg) msg.edit.assert_called_once() self.assertIn("content", msg.edit.call_args[1]) self.assertEqual(ret_val, msg) - def test_send_prompt_gets_dev_core_channel(self): + @helpers.async_test + async def test_send_prompt_gets_dev_core_channel(self): """The dev-core channel should be retrieved if an extant message isn't given.""" subtests = ( (self.bot.get_channel, self.mock_get_channel), @@ -81,31 +82,34 @@ class SyncerSendPromptTests(unittest.TestCase): for method, mock_ in subtests: with self.subTest(method=method, msg=mock_.__name__): mock_() - asyncio.run(self.syncer._send_prompt()) + await self.syncer._send_prompt() method.assert_called_once_with(constants.Channels.devcore) - def test_send_prompt_returns_None_if_channel_fetch_fails(self): + @helpers.async_test + async def test_send_prompt_returns_None_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" self.bot.get_channel.return_value = None self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - ret_val = asyncio.run(self.syncer._send_prompt()) + ret_val = await self.syncer._send_prompt() self.assertIsNone(ret_val) - def test_send_prompt_sends_and_returns_new_message_if_not_given(self): + @helpers.async_test + async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): """A new message mentioning core devs should be sent and returned if message isn't given.""" for mock_ in (self.mock_get_channel, self.mock_fetch_channel): with self.subTest(msg=mock_.__name__): mock_channel, mock_message = mock_() - ret_val = asyncio.run(self.syncer._send_prompt()) + ret_val = await self.syncer._send_prompt() mock_channel.send.assert_called_once() self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) self.assertEqual(ret_val, mock_message) - def test_send_prompt_adds_reactions(self): + @helpers.async_test + async def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" extant_message = helpers.MockMessage() subtests = ( @@ -119,7 +123,7 @@ class SyncerSendPromptTests(unittest.TestCase): with self.subTest(msg=subtest_msg): _, mock_message = mock_() - asyncio.run(self.syncer._send_prompt(message_arg)) + await self.syncer._send_prompt(message_arg) calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] mock_message.add_reaction.assert_has_calls(calls) @@ -207,7 +211,8 @@ class SyncerConfirmationTests(unittest.TestCase): ret_val = self.syncer._reaction_check(*args) self.assertFalse(ret_val) - def test_wait_for_confirmation(self): + @helpers.async_test + async def test_wait_for_confirmation(self): """The message should always be edited and only return True if the emoji is a check mark.""" subtests = ( (constants.Emojis.check_mark, True, None), @@ -227,7 +232,7 @@ class SyncerConfirmationTests(unittest.TestCase): self.bot.wait_for.side_effect = side_effect # Call the function - actual_return = asyncio.run(self.syncer._wait_for_confirmation(member, message)) + actual_return = await self.syncer._wait_for_confirmation(member, message) # Perform assertions self.bot.wait_for.assert_called_once() @@ -253,7 +258,8 @@ class SyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) - def test_sync_respects_confirmation_result(self): + @helpers.async_test + async def test_sync_respects_confirmation_result(self): """The sync should abort if confirmation fails and continue if confirmed.""" mock_message = helpers.MockMessage() subtests = ( @@ -273,7 +279,7 @@ class SyncerSyncTests(unittest.TestCase): ) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild)) + await self.syncer.sync(guild) self.syncer._get_diff.assert_called_once_with(guild) self.syncer._get_confirmation_result.assert_called_once() @@ -283,7 +289,8 @@ class SyncerSyncTests(unittest.TestCase): else: self.syncer._sync.assert_not_called() - def test_sync_diff_size(self): + @helpers.async_test + async def test_sync_diff_size(self): """The diff size should be correctly calculated.""" subtests = ( (6, _Diff({1, 2}, {3, 4}, {5, 6})), @@ -299,13 +306,14 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild)) + await self.syncer.sync(guild) self.syncer._get_diff.assert_called_once_with(guild) self.syncer._get_confirmation_result.assert_called_once() self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) - def test_sync_message_edited(self): + @helpers.async_test + async def test_sync_message_edited(self): """The message should be edited if one was sent, even if the sync has an API error.""" subtests = ( (None, None, False), @@ -321,13 +329,14 @@ class SyncerSyncTests(unittest.TestCase): ) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild)) + await self.syncer.sync(guild) if should_edit: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - def test_sync_confirmation_context_redirect(self): + @helpers.async_test + async def test_sync_confirmation_context_redirect(self): """If ctx is given, a new message should be sent and author should be ctx's author.""" mock_member = helpers.MockMember() subtests = ( @@ -343,7 +352,7 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() - asyncio.run(self.syncer.sync(guild, ctx)) + await self.syncer.sync(guild, ctx) if ctx is not None: ctx.send.assert_called_once() @@ -352,7 +361,8 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - def test_confirmation_result_small_diff(self): + @helpers.async_test + async def test_confirmation_result_small_diff(self): """Should always return True and the given message if the diff size is too small.""" self.syncer.MAX_DIFF = 3 author = helpers.MockMember() @@ -364,14 +374,15 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._wait_for_confirmation = helpers.AsyncMock() coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = asyncio.run(coro) + result, actual_message = await coro self.assertTrue(result) self.assertEqual(actual_message, expected_message) self.syncer._send_prompt.assert_not_called() self.syncer._wait_for_confirmation.assert_not_called() - def test_confirmation_result_large_diff(self): + @helpers.async_test + async def test_confirmation_result_large_diff(self): """Should return True if confirmed and False if _send_prompt fails or aborted.""" self.syncer.MAX_DIFF = 3 author = helpers.MockMember() @@ -389,7 +400,7 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = asyncio.run(coro) + actual_result, actual_message = await coro self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None self.assertIs(actual_result, expected_result) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index f66adfea1..98c9afc0d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -91,7 +90,8 @@ class SyncCogTests(SyncCogTestCase): sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - def test_sync_cog_sync_guild(self): + @helpers.async_test + async def test_sync_cog_sync_guild(self): """Roles and users should be synced only if a guild is successfully retrieved.""" for guild in (helpers.MockGuild(), None): with self.subTest(guild=guild): @@ -101,7 +101,7 @@ class SyncCogTests(SyncCogTestCase): self.bot.get_guild = mock.MagicMock(return_value=guild) - asyncio.run(self.cog.sync_guild()) + await self.cog.sync_guild() self.bot.wait_until_guild_available.assert_called_once() self.bot.get_guild.assert_called_once_with(constants.Guild.id) @@ -113,29 +113,31 @@ class SyncCogTests(SyncCogTestCase): self.cog.role_syncer.sync.assert_called_once_with(guild) self.cog.user_syncer.sync.assert_called_once_with(guild) - def patch_user_helper(self, side_effect: BaseException) -> None: + async def patch_user_helper(self, side_effect: BaseException) -> None: """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" self.bot.api_client.patch.reset_mock(side_effect=True) self.bot.api_client.patch.side_effect = side_effect user_id, updated_information = 5, {"key": 123} - asyncio.run(self.cog.patch_user(user_id, updated_information)) + await self.cog.patch_user(user_id, updated_information) self.bot.api_client.patch.assert_called_once_with( f"bot/users/{user_id}", json=updated_information, ) - def test_sync_cog_patch_user(self): + @helpers.async_test + async def test_sync_cog_patch_user(self): """A PATCH request should be sent and 404 errors ignored.""" for side_effect in (None, self.response_error(404)): with self.subTest(side_effect=side_effect): - self.patch_user_helper(side_effect) + await self.patch_user_helper(side_effect) - def test_sync_cog_patch_user_non_404(self): + @helpers.async_test + async def test_sync_cog_patch_user_non_404(self): """A PATCH request should be sent and the error raised if it's not a 404.""" with self.assertRaises(ResponseCodeError): - self.patch_user_helper(self.response_error(500)) + await self.patch_user_helper(self.response_error(500)) class SyncCogListenerTests(SyncCogTestCase): @@ -145,7 +147,8 @@ class SyncCogListenerTests(SyncCogTestCase): super().setUp() self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) - def test_sync_cog_on_guild_role_create(self): + @helpers.async_test + async def test_sync_cog_on_guild_role_create(self): """A POST request should be sent with the new role's data.""" self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) @@ -157,20 +160,22 @@ class SyncCogListenerTests(SyncCogTestCase): "position": 23, } role = helpers.MockRole(**role_data) - asyncio.run(self.cog.on_guild_role_create(role)) + await self.cog.on_guild_role_create(role) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - def test_sync_cog_on_guild_role_delete(self): + @helpers.async_test + async def test_sync_cog_on_guild_role_delete(self): """A DELETE request should be sent.""" self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) role = helpers.MockRole(id=99) - asyncio.run(self.cog.on_guild_role_delete(role)) + await self.cog.on_guild_role_delete(role) self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - def test_sync_cog_on_guild_role_update(self): + @helpers.async_test + async def test_sync_cog_on_guild_role_update(self): """A PUT request should be sent if the colour, name, permissions, or position changes.""" self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) @@ -197,7 +202,7 @@ class SyncCogListenerTests(SyncCogTestCase): before_role = helpers.MockRole(**role_data) after_role = helpers.MockRole(**after_role_data) - asyncio.run(self.cog.on_guild_role_update(before_role, after_role)) + await self.cog.on_guild_role_update(before_role, after_role) if should_put: self.bot.api_client.put.assert_called_once_with( @@ -207,19 +212,21 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.put.assert_not_called() - def test_sync_cog_on_member_remove(self): + @helpers.async_test + async def test_sync_cog_on_member_remove(self): """Member should patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) member = helpers.MockMember() - asyncio.run(self.cog.on_member_remove(member)) + await self.cog.on_member_remove(member) self.cog.patch_user.assert_called_once_with( member.id, updated_information={"in_guild": False} ) - def test_sync_cog_on_member_update_roles(self): + @helpers.async_test + async def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) @@ -228,12 +235,13 @@ class SyncCogListenerTests(SyncCogTestCase): before_member = helpers.MockMember(roles=before_roles) after_member = helpers.MockMember(roles=before_roles[1:]) - asyncio.run(self.cog.on_member_update(before_member, after_member)) + await self.cog.on_member_update(before_member, after_member) data = {"roles": sorted(role.id for role in after_member.roles)} self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) - def test_sync_cog_on_member_update_other(self): + @helpers.async_test + async def test_sync_cog_on_member_update_other(self): """Members should not be patched if other attributes have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) @@ -250,11 +258,12 @@ class SyncCogListenerTests(SyncCogTestCase): before_member = helpers.MockMember(**{attribute: old_value}) after_member = helpers.MockMember(**{attribute: new_value}) - asyncio.run(self.cog.on_member_update(before_member, after_member)) + await self.cog.on_member_update(before_member, after_member) self.cog.patch_user.assert_not_called() - def test_sync_cog_on_user_update(self): + @helpers.async_test + async def test_sync_cog_on_user_update(self): """A user should be patched only if the name, discriminator, or avatar changes.""" self.assertTrue(self.cog.on_user_update.__cog_listener__) @@ -281,7 +290,7 @@ class SyncCogListenerTests(SyncCogTestCase): before_user = helpers.MockUser(**before_data) after_user = helpers.MockUser(**after_data) - asyncio.run(self.cog.on_user_update(before_user, after_user)) + await self.cog.on_user_update(before_user, after_user) if should_patch: self.cog.patch_user.assert_called_once() @@ -297,7 +306,7 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.cog.patch_user.assert_not_called() - def on_member_join_helper(self, side_effect: Exception) -> dict: + async def on_member_join_helper(self, side_effect: Exception) -> dict: """ Helper to set `side_effect` for on_member_join and assert a PUT request was sent. @@ -321,7 +330,7 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.put.side_effect = side_effect try: - asyncio.run(self.cog.on_member_join(member)) + await self.cog.on_member_join(member) except Exception: raise finally: @@ -332,38 +341,44 @@ class SyncCogListenerTests(SyncCogTestCase): return data - def test_sync_cog_on_member_join(self): + @helpers.async_test + async def test_sync_cog_on_member_join(self): """Should PUT user's data or POST it if the user doesn't exist.""" for side_effect in (None, self.response_error(404)): with self.subTest(side_effect=side_effect): self.bot.api_client.post.reset_mock() - data = self.on_member_join_helper(side_effect) + data = await self.on_member_join_helper(side_effect) if side_effect: self.bot.api_client.post.assert_called_once_with("bot/users", json=data) else: self.bot.api_client.post.assert_not_called() - def test_sync_cog_on_member_join_non_404(self): + @helpers.async_test + async def test_sync_cog_on_member_join_non_404(self): """ResponseCodeError should be re-raised if status code isn't a 404.""" - self.assertRaises(ResponseCodeError, self.on_member_join_helper, self.response_error(500)) + with self.assertRaises(ResponseCodeError): + await self.on_member_join_helper(self.response_error(500)) + self.bot.api_client.post.assert_not_called() class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" - def test_sync_roles_command(self): + @helpers.async_test + async def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() - asyncio.run(self.cog.sync_roles_command.callback(self.cog, ctx)) + await self.cog.sync_roles_command.callback(self.cog, ctx) self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - def test_sync_users_command(self): + @helpers.async_test + async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() - asyncio.run(self.cog.sync_users_command.callback(self.cog, ctx)) + await self.cog.sync_users_command.callback(self.cog, ctx) self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 8324b99cd..14fb2577a 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -40,53 +39,58 @@ class RoleSyncerDiffTests(unittest.TestCase): return guild - def test_empty_diff_for_identical_roles(self): + @helpers.async_test + async def test_empty_diff_for_identical_roles(self): """No differences should be found if the roles in the guild and DB are identical.""" self.bot.api_client.get.return_value = [fake_role()] guild = self.get_guild(fake_role()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), set()) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_updated_roles(self): + @helpers.async_test + async def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" updated_role = fake_role(id=41, name="new") self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()] guild = self.get_guild(updated_role, fake_role()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), {_Role(**updated_role)}, set()) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_roles(self): + @helpers.async_test + async def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" new_role = fake_role(id=41, name="new") self.bot.api_client.get.return_value = [fake_role()] guild = self.get_guild(fake_role(), new_role) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_Role(**new_role)}, set(), set()) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_deleted_roles(self): + @helpers.async_test + async def test_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" deleted_role = fake_role(id=61, name="deleted") self.bot.api_client.get.return_value = [fake_role(), deleted_role] guild = self.get_guild(fake_role()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), {_Role(**deleted_role)}) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_updated_and_deleted_roles(self): + @helpers.async_test + async def test_diff_for_new_updated_and_deleted_roles(self): """When roles are added, updated, and removed, all of them are returned properly.""" new = fake_role(id=41, name="new") updated = fake_role(id=71, name="updated") @@ -99,7 +103,7 @@ class RoleSyncerDiffTests(unittest.TestCase): ] guild = self.get_guild(fake_role(), new, updated) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)}) self.assertEqual(actual_diff, expected_diff) @@ -112,13 +116,14 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - def test_sync_created_roles(self): + @helpers.async_test + async def test_sync_created_roles(self): """Only POST requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(role_tuples, set(), set()) - asyncio.run(self.syncer._sync(diff)) + await self.syncer._sync(diff) calls = [mock.call("bot/roles", json=role) for role in roles] self.bot.api_client.post.assert_has_calls(calls, any_order=True) @@ -127,13 +132,14 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_updated_roles(self): + @helpers.async_test + async def test_sync_updated_roles(self): """Only PUT requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), role_tuples, set()) - asyncio.run(self.syncer._sync(diff)) + await self.syncer._sync(diff) calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles] self.bot.api_client.put.assert_has_calls(calls, any_order=True) @@ -142,13 +148,14 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_deleted_roles(self): + @helpers.async_test + async def test_sync_deleted_roles(self): """Only DELETE requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] role_tuples = {_Role(**role) for role in roles} diff = _Diff(set(), set(), role_tuples) - asyncio.run(self.syncer._sync(diff)) + await self.syncer._sync(diff) calls = [mock.call(f"bot/roles/{role['id']}") for role in roles] self.bot.api_client.delete.assert_has_calls(calls, any_order=True) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index e9f9db2ea..421bf6bb6 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock @@ -43,62 +42,68 @@ class UserSyncerDiffTests(unittest.TestCase): return guild - def test_empty_diff_for_no_users(self): + @helpers.async_test + async def test_empty_diff_for_no_users(self): """When no users are given, an empty diff should be returned.""" guild = self.get_guild() - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) - def test_empty_diff_for_identical_users(self): + @helpers.async_test + async def test_empty_diff_for_identical_users(self): """No differences should be found if the users in the guild and DB are identical.""" self.bot.api_client.get.return_value = [fake_user()] guild = self.get_guild(fake_user()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_updated_users(self): + @helpers.async_test + async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] guild = self.get_guild(updated_user, fake_user()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), {_User(**updated_user)}, None) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_users(self): + @helpers.async_test + async def test_diff_for_new_users(self): """Only new users should be added to the 'created' set of the diff.""" new_user = fake_user(id=99, name="new") self.bot.api_client.get.return_value = [fake_user()] guild = self.get_guild(fake_user(), new_user) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_User(**new_user)}, set(), None) self.assertEqual(actual_diff, expected_diff) - def test_diff_sets_in_guild_false_for_leaving_users(self): + @helpers.async_test + async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" leaving_user = fake_user(id=63, in_guild=False) self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] guild = self.get_guild(fake_user()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), {_User(**leaving_user)}, None) self.assertEqual(actual_diff, expected_diff) - def test_diff_for_new_updated_and_leaving_users(self): + @helpers.async_test + async def test_diff_for_new_updated_and_leaving_users(self): """When users are added, updated, and removed, all of them are returned properly.""" new_user = fake_user(id=99, name="new") updated_user = fake_user(id=55, name="updated") @@ -107,17 +112,18 @@ class UserSyncerDiffTests(unittest.TestCase): self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] guild = self.get_guild(fake_user(), new_user, updated_user) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) self.assertEqual(actual_diff, expected_diff) - def test_empty_diff_for_db_users_not_in_guild(self): + @helpers.async_test + async def test_empty_diff_for_db_users_not_in_guild(self): """When the DB knows a user the guild doesn't, no difference is found.""" self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] guild = self.get_guild(fake_user()) - actual_diff = asyncio.run(self.syncer._get_diff(guild)) + actual_diff = await self.syncer._get_diff(guild) expected_diff = (set(), set(), None) self.assertEqual(actual_diff, expected_diff) @@ -130,13 +136,14 @@ class UserSyncerSyncTests(unittest.TestCase): self.bot = helpers.MockBot() self.syncer = UserSyncer(self.bot) - def test_sync_created_users(self): + @helpers.async_test + async def test_sync_created_users(self): """Only POST requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] user_tuples = {_User(**user) for user in users} diff = _Diff(user_tuples, set(), None) - asyncio.run(self.syncer._sync(diff)) + await self.syncer._sync(diff) calls = [mock.call("bot/users", json=user) for user in users] self.bot.api_client.post.assert_has_calls(calls, any_order=True) @@ -145,13 +152,14 @@ class UserSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - def test_sync_updated_users(self): + @helpers.async_test + async def test_sync_updated_users(self): """Only PUT requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] user_tuples = {_User(**user) for user in users} diff = _Diff(set(), user_tuples, None) - asyncio.run(self.syncer._sync(diff)) + await self.syncer._sync(diff) calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] self.bot.api_client.put.assert_has_calls(calls, any_order=True) -- cgit v1.2.3 From 22a55534ef13990815a6f69d361e2a12693075d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Feb 2020 09:16:46 -0800 Subject: Tests: fix unawaited error for MockAPIClient This error is due to the use of an actual instance of APIClient as the spec for the mock. recreate() is called in __init__ which in turn creates a task for the _create_session coroutine. The approach to the solution is to use the type for the spec rather than and instance, thus avoiding any call of __init__. However, without an instance, instance attributes will not be included in the spec. Therefore, they are defined as class attributes on the actual APIClient class definition and given default values. Alternatively, a subclass of APIClient could have been made in the tests.helpers module to define those class attributes. However, it seems easier to maintain if the attributes are in the original class definition. --- bot/api.py | 5 ++++- tests/helpers.py | 6 +----- 2 files changed, 5 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/bot/api.py b/bot/api.py index a9d2baa4d..d5880ba18 100644 --- a/bot/api.py +++ b/bot/api.py @@ -32,6 +32,9 @@ class ResponseCodeError(ValueError): class APIClient: """Django Site API wrapper.""" + session: Optional[aiohttp.ClientSession] = None + loop: asyncio.AbstractEventLoop = None + def __init__(self, loop: asyncio.AbstractEventLoop, **kwargs): auth_headers = { 'Authorization': f"Token {Keys.site_api}" @@ -42,7 +45,7 @@ class APIClient: else: kwargs['headers'] = auth_headers - self.session: Optional[aiohttp.ClientSession] = None + self.session = None self.loop = loop self._ready = asyncio.Event(loop=loop) diff --git a/tests/helpers.py b/tests/helpers.py index a40673bb9..9d9dd5da6 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -337,10 +337,6 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): self.mention = f"@{self.name}" -# Create an APIClient instance to get a realistic MagicMock of `bot.api.APIClient` -api_client_instance = APIClient(loop=unittest.mock.MagicMock()) - - class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock APIClient objects. @@ -350,7 +346,7 @@ class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): """ def __init__(self, **kwargs) -> None: - super().__init__(spec_set=api_client_instance, **kwargs) + super().__init__(spec_set=APIClient, **kwargs) # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` -- cgit v1.2.3 From 6703e3b7db972017764f91232a82c163be2cd588 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 13 Feb 2020 17:35:34 +0100 Subject: Update the tests accordingly to reflect the new changes --- tests/bot/cogs/test_information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 296c3c556..deae7ebad 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -153,9 +153,9 @@ class InformationCogTests(unittest.TestCase): **Counts** Members: {self.ctx.guild.member_count:,} Roles: {len(self.ctx.guild.roles)} - Text Channels: 1 - Voice Channels: 1 - Channel categories: 1 + Category channels: 1 + Text channels: 1 + Voice channels: 1 **Members** {constants.Emojis.status_online} 2 -- cgit v1.2.3 From 33769405549efc3c0571cd1b2da5fa0b59ec742a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:23:38 +0100 Subject: Split assertion onto separate lines Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 293efed0f..b5190cd7f 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -35,7 +35,8 @@ class SnekboxTests(unittest.TestCase): @async_test async def test_upload_output_reject_too_long(self): """Reject output longer than MAX_PASTE_LEN.""" - self.assertEqual(await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)), "too long to upload") + result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) + self.assertEqual(result, "too long to upload") @async_test async def test_upload_output(self): -- cgit v1.2.3 From 2974d489494370f364c44899a529f5e93c89bedc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:25:55 +0100 Subject: Split assertions onto separate lines Reads better as separate lines Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index b5190cd7f..01525110d 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -88,7 +88,8 @@ class SnekboxTests(unittest.TestCase): ) for stdout, returncode, expected in cases: with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - self.assertEqual(self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}), expected) + actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) def test_get_results_message_invalid_signal(self, mock_Signals: Mock): @@ -114,7 +115,8 @@ class SnekboxTests(unittest.TestCase): ) for stdout, returncode, expected in cases: with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - self.assertEqual(self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}), expected) + actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) + self.assertEqual(actual, expected) @async_test async def test_format_output(self): @@ -321,7 +323,8 @@ class SnekboxTests(unittest.TestCase): with self.subTest(msg=f'Messages with {testname} return {expected}'): ctx = MockContext() ctx.message = ctx_msg - self.assertEqual(snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg), expected) + actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) + self.assertEqual(actual, expected) def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" @@ -351,7 +354,8 @@ class SnekboxTests(unittest.TestCase): ) for reaction, user, expected, testname in cases: with self.subTest(msg=f'Test with {testname} and expected return {expected}'): - self.assertEqual(snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user), expected) + actual = snekbox.predicate_eval_emoji_reaction(valid_ctx, reaction, user) + self.assertEqual(actual, expected) class SnekboxSetupTests(unittest.TestCase): -- cgit v1.2.3 From dd9c250253a7bb24751f2de274dfa168efafb717 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:29:22 +0100 Subject: Delete additional informations from subtest Reduce visual clutter Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 01525110d..f1f03ab2f 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -76,7 +76,7 @@ class SnekboxTests(unittest.TestCase): ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), ) for case, expected, testname in cases: - with self.subTest(msg=f'Extract code from {testname}.', case=case, expected=expected): + with self.subTest(msg=f'Extract code from {testname}.'): self.assertEqual(self.cog.prepare_input(case), expected) def test_get_results_message(self): -- cgit v1.2.3 From b5a1bf7c6ca467cef40a429cc8ca02314526801c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 18 Feb 2020 18:30:39 +0100 Subject: Use a space instead of an empty string in test_get_status_emoji Because of the stripping, it should still be considered as empty Co-Authored-By: Mark --- tests/bot/cogs/test_snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index f1f03ab2f..94ff685c4 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -109,7 +109,7 @@ class SnekboxTests(unittest.TestCase): def test_get_status_emoji(self): """Return emoji according to the eval result.""" cases = ( - ('', -1, ':warning:'), + (' ', -1, ':warning:'), ('Hello world!', 0, ':white_check_mark:'), ('Invalid beard size', -1, ':x:') ) -- cgit v1.2.3 From c1a998ca246e153333438ff91bce200bf74cc0f5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:38:24 +0100 Subject: Assert return value of Snekbox.post_eval --- tests/bot/cogs/test_snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 94ff685c4..cf07aad71 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -25,7 +25,9 @@ class SnekboxTests(unittest.TestCase): @async_test async def test_post_eval(self): """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - await self.cog.post_eval("import random") + self.mocked_post.json.return_value = {'lemon': 'AI'} + + self.assertEqual(await self.cog.post_eval("import random"), {'lemon': 'AI'}) self.bot.http_session.post.assert_called_once_with( URLs.snekbox_eval_api, json={"input": "import random"}, -- cgit v1.2.3 From 12f43fc09406dee8cb1b36757fc2af7ae799a9d5 Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 18 Feb 2020 18:44:00 +0100 Subject: Use kwargs to set mock attributes --- tests/bot/cogs/test_snekbox.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index cf07aad71..112c923c8 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -306,15 +306,9 @@ class SnekboxTests(unittest.TestCase): def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" - msg0 = MockMessage() - msg0.id = 1 - msg0.content = 'abc' - msg1 = MockMessage() - msg1.id = 2 - msg1.content = 'abcdef' - msg2 = MockMessage() - msg2.id = 1 - msg2.content = 'abcdef' + msg0 = MockMessage(id=1, content='abc') + msg1 = MockMessage(id=2, content='abcdef') + msg2 = MockMessage(id=1, content='abcdef') cases = ( (msg0, msg0, False, 'same ID, same content'), @@ -323,29 +317,21 @@ class SnekboxTests(unittest.TestCase): ) for ctx_msg, new_msg, expected, testname in cases: with self.subTest(msg=f'Messages with {testname} return {expected}'): - ctx = MockContext() - ctx.message = ctx_msg + ctx = MockContext(message=ctx_msg) actual = snekbox.predicate_eval_message_edit(ctx, ctx_msg, new_msg) self.assertEqual(actual, expected) def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" - valid_reaction = MockReaction() - valid_reaction.message.id = 1 + valid_reaction = MockReaction(message=MockMessage(id=1)) valid_reaction.__str__.return_value = '🔁' - valid_ctx = MockContext() - valid_ctx.message.id = 1 - valid_ctx.author.id = 2 - valid_user = MockUser() - valid_user.id = 2 - - invalid_reaction_id = MockReaction() - invalid_reaction_id.message.id = 42 + valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) + valid_user = MockUser(id=2) + + invalid_reaction_id = MockReaction(message=MockMessage(id=42)) invalid_reaction_id.__str__.return_value = '🔁' - invalid_user_id = MockUser() - invalid_user_id.id = 42 - invalid_reaction_str = MockReaction() - invalid_reaction_str.message.id = 1 + invalid_user_id = MockUser(id=42) + invalid_reaction_str = MockReaction(message=MockMessage(id=1)) invalid_reaction_str.__str__.return_value = ':longbeard:' cases = ( -- cgit v1.2.3 From e5a7af3811f7f2687026254f44194b3a16459ca2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Feb 2020 09:26:42 -0800 Subject: Sync: add confirmation timeout and max diff to config --- bot/cogs/sync/syncers.py | 25 +++++++++++-------------- bot/constants.py | 7 +++++++ config-default.yml | 4 ++++ tests/bot/cogs/sync/test_base.py | 4 ++-- 4 files changed, 24 insertions(+), 16 deletions(-) (limited to 'tests') diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 23039d1fc..43a8f2b62 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -26,9 +26,6 @@ class Syncer(abc.ABC): _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developer}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - CONFIRM_TIMEOUT = 60 * 5 # 5 minutes - MAX_DIFF = 10 - def __init__(self, bot: Bot) -> None: self.bot = bot @@ -50,7 +47,7 @@ class Syncer(abc.ABC): msg_content = ( f'Possible cache issue while syncing {self.name}s. ' - f'More than {self.MAX_DIFF} {self.name}s were changed. ' + f'More than {constants.Sync.max_diff} {self.name}s were changed. ' f'React to confirm or abort the sync.' ) @@ -110,8 +107,8 @@ class Syncer(abc.ABC): Uses the `_reaction_check` function to determine if a reaction is valid. - If there is no reaction within `CONFIRM_TIMEOUT` seconds, return False. To acknowledge the - reaction (or lack thereof), `message` will be edited. + If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. + To acknowledge the reaction (or lack thereof), `message` will be edited. """ # Preserve the core-dev role mention in the message edits so users aren't confused about # where notifications came from. @@ -123,7 +120,7 @@ class Syncer(abc.ABC): reaction, _ = await self.bot.wait_for( 'reaction_add', check=partial(self._reaction_check, author, message), - timeout=self.CONFIRM_TIMEOUT + timeout=constants.Sync.confirm_timeout ) except TimeoutError: # reaction will remain none thus sync will be aborted in the finally block below. @@ -159,15 +156,15 @@ class Syncer(abc.ABC): """ Prompt for confirmation and return a tuple of the result and the prompt message. - `diff_size` is the size of the diff of the sync. If it is greater than `MAX_DIFF`, the - prompt will be sent. The `author` is the invoked of the sync and the `message` is an extant - message to edit to display the prompt. + `diff_size` is the size of the diff of the sync. If it is greater than + `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the + sync and the `message` is an extant message to edit to display the prompt. If confirmed or no confirmation was needed, the result is True. The returned message will either be the given `message` or a new one which was created when sending the prompt. """ log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > self.MAX_DIFF: + if diff_size > constants.Sync.max_diff: message = await self._send_prompt(message) if not message: return False, None # Couldn't get channel. @@ -182,9 +179,9 @@ class Syncer(abc.ABC): """ Synchronise the database with the cache of `guild`. - If the differences between the cache and the database are greater than `MAX_DIFF`, then - a confirmation prompt will be sent to the dev-core channel. The confirmation can be - optionally redirect to `ctx` instead. + If the differences between the cache and the database are greater than + `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core + channel. The confirmation can be optionally redirect to `ctx` instead. """ log.info(f"Starting {self.name} syncer.") diff --git a/bot/constants.py b/bot/constants.py index 6279388de..81ce3e903 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -539,6 +539,13 @@ class RedirectOutput(metaclass=YAMLGetter): delete_delay: int +class Sync(metaclass=YAMLGetter): + section = 'sync' + + confirm_timeout: int + max_diff: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw diff --git a/config-default.yml b/config-default.yml index 74dcc1862..0ebdc4080 100644 --- a/config-default.yml +++ b/config-default.yml @@ -430,6 +430,10 @@ redirect_output: delete_invocation: true delete_delay: 15 +sync: + confirm_timeout: 300 + max_diff: 10 + duck_pond: threshold: 5 custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE, *DUCKY_HUNT, *DUCKY_WIZARD, *DUCKY_PARTY, *DUCKY_ANGEL, *DUCKY_MAUL, *DUCKY_SANTA] diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 0539f5683..e6a6f9688 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -361,10 +361,10 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) + @mock.patch.object(constants.Sync, "max_diff", new=3) @helpers.async_test async def test_confirmation_result_small_diff(self): """Should always return True and the given message if the diff size is too small.""" - self.syncer.MAX_DIFF = 3 author = helpers.MockMember() expected_message = helpers.MockMessage() @@ -381,10 +381,10 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._send_prompt.assert_not_called() self.syncer._wait_for_confirmation.assert_not_called() + @mock.patch.object(constants.Sync, "max_diff", new=3) @helpers.async_test async def test_confirmation_result_large_diff(self): """Should return True if confirmed and False if _send_prompt fails or aborted.""" - self.syncer.MAX_DIFF = 3 author = helpers.MockMember() mock_message = helpers.MockMessage() -- cgit v1.2.3 From aa12d72c7ad6ad54f82f113da1e916cee1903eaf Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 17:22:23 +0000 Subject: Remove tests for custom bot log --- tests/bot/test_api.py | 64 ++------------------------------------------------- 1 file changed, 2 insertions(+), 62 deletions(-) (limited to 'tests') diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index 5a88adc5c..bdfcc73e4 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -1,9 +1,7 @@ -import logging import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from bot import api -from tests.base import LoggingTestCase from tests.helpers import async_test @@ -34,7 +32,7 @@ class APIClientTests(unittest.TestCase): self.assertEqual(error.response_text, "") self.assertIs(error.response, self.error_api_response) - def test_responde_code_error_string_representation_default_initialization(self): + def test_response_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: ") @@ -76,61 +74,3 @@ class APIClientTests(unittest.TestCase): 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.""" - log = logging.getLogger("bot.api") - - with self.assertLogs(logger=log, 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__) -- cgit v1.2.3 From d3f4673c1a1c3f5213840e756c5f35f7c70d46f6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 12:39:25 +0100 Subject: Use mixin-composition not inheritance for LoggingTestCase We used inheritence to add additional logging assertion methods to unittest's TestCase class. However, with the introduction of the new IsolatedAsyncioTestCase this extension strategy means we'd have to create multiple child classes to be able to use the extended functionality in all of the TestCase variants. Since that leads to undesirable code reuse and an inheritance relationship is not at all needed, I've switched to a mixin-composition based approach that allows the user to extend the functionality of any TestCase variant with a mixin where needed. --- tests/base.py | 10 +++++++--- tests/bot/cogs/test_duck_pond.py | 2 +- tests/test_base.py | 18 ++++++------------ 3 files changed, 14 insertions(+), 16 deletions(-) (limited to 'tests') diff --git a/tests/base.py b/tests/base.py index 029a249ed..21a57716a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,5 +1,4 @@ import logging -import unittest from contextlib import contextmanager @@ -16,8 +15,13 @@ class _CaptureLogHandler(logging.Handler): self.records.append(record) -class LoggingTestCase(unittest.TestCase): - """TestCase subclass that adds more logging assertion tools.""" +class LoggingTestsMixin: + """ + A mixin that defines additional test methods for logging behavior. + + This mixin relies on the availability of the `fail` attribute defined by the + test classes included in Python's unittest method to signal test failure. + """ @contextmanager def assertNotLogs(self, logger=None, level=None, msg=None): diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index d07b2bce1..320cbd5c5 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -14,7 +14,7 @@ from tests import helpers MODULE_PATH = "bot.cogs.duck_pond" -class DuckPondTests(base.LoggingTestCase): +class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): """Tests for DuckPond functionality.""" @classmethod diff --git a/tests/test_base.py b/tests/test_base.py index a16e2af8f..23abb1dfd 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,7 +3,11 @@ import unittest import unittest.mock -from tests.base import LoggingTestCase, _CaptureLogHandler +from tests.base import LoggingTestsMixin, _CaptureLogHandler + + +class LoggingTestCase(LoggingTestsMixin): + pass class LoggingTestCaseTests(unittest.TestCase): @@ -18,19 +22,9 @@ class LoggingTestCaseTests(unittest.TestCase): try: with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): pass - except AssertionError: + except AssertionError: # pragma: no cover self.fail("`self.assertNotLogs` raised an AssertionError when it should not!") - @unittest.mock.patch("tests.base.LoggingTestCase.assertNotLogs") - def test_the_test_function_assert_not_logs_does_not_raise_with_no_logs(self, assertNotLogs): - """Test if test_assert_not_logs_does_not_raise_with_no_logs captures exception correctly.""" - assertNotLogs.return_value = iter([None]) - assertNotLogs.side_effect = AssertionError - - message = "`self.assertNotLogs` raised an AssertionError when it should not!" - with self.assertRaises(AssertionError, msg=message): - self.test_assert_not_logs_does_not_raise_with_no_logs() - def test_assert_not_logs_raises_correct_assertion_error_when_logs_are_emitted(self): """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" msg_regex = ( -- cgit v1.2.3 From 135d6daa4804574935cd788c5baec656765f484b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 13:05:10 +0100 Subject: Use IsolatedAsyncioTestCase instead of async_test Since we upgraded to Python 3.8, we can now use the new IsolatedAsyncioTestCase test class to use coroutine-based test methods instead of our own, custom async_test decorator. I have changed the base class for all of our test classes that use coroutine-based test methods and removed the now obsolete decorator from our helpers. --- tests/bot/cogs/test_duck_pond.py | 11 +---------- tests/bot/rules/__init__.py | 2 +- tests/bot/rules/test_attachments.py | 4 +--- tests/bot/rules/test_burst.py | 4 +--- tests/bot/rules/test_burst_shared.py | 4 +--- tests/bot/rules/test_chars.py | 4 +--- tests/bot/rules/test_discord_emojis.py | 4 +--- tests/bot/rules/test_duplicates.py | 4 +--- tests/bot/rules/test_links.py | 4 +--- tests/bot/rules/test_mentions.py | 4 +--- tests/bot/rules/test_newlines.py | 5 +---- tests/bot/rules/test_role_mentions.py | 4 +--- tests/bot/test_api.py | 4 +--- tests/helpers.py | 17 ----------------- tests/test_helpers.py | 8 -------- 15 files changed, 13 insertions(+), 70 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 320cbd5c5..6406f0737 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -14,7 +14,7 @@ from tests import helpers MODULE_PATH = "bot.cogs.duck_pond" -class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): +class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): """Tests for DuckPond functionality.""" @classmethod @@ -88,7 +88,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return): self.assertEqual(expected_return, actual_return) - @helpers.async_test async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self): """The `has_green_checkmark` method should only return `True` if one is present.""" test_cases = ( @@ -172,7 +171,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): nonstaffers = [helpers.MockMember() for _ in range(nonstaff)] return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers) - @helpers.async_test async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self): """The `count_ducks` method should return the number of unique staffers who gave a duck.""" test_cases = ( @@ -280,7 +278,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count): self.assertEqual(expected_count, actual_count) - @helpers.async_test async def test_relay_message_correctly_relays_content_and_attachments(self): """The `relay_message` method should correctly relay message content and attachments.""" send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" @@ -307,7 +304,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): message.add_reaction.assert_called_once_with(self.checkmark_emoji) @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) - @helpers.async_test async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -327,7 +323,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) - @helpers.async_test async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -360,7 +355,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): payload.emoji.name = emoji_name return payload - @helpers.async_test async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self): """The `on_raw_reaction_add` event handler should ignore irrelevant emojis.""" test_values = ( @@ -434,7 +428,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): return channel, message, member, payload - @helpers.async_test async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self): """The `on_raw_reaction_add` event handler should return for bot users or non-staff members.""" channel_id = 1234 @@ -485,7 +478,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): # Assert that we've made it past `self.is_staff` is_staff.assert_called_once() - @helpers.async_test async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self): """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold.""" test_cases = ( @@ -515,7 +507,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.TestCase): if should_relay: relay_message.assert_called_once_with(message) - @helpers.async_test async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self): """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks.""" checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji) diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py index 36c986fe1..0233e7939 100644 --- a/tests/bot/rules/__init__.py +++ b/tests/bot/rules/__init__.py @@ -12,7 +12,7 @@ class DisallowedCase(NamedTuple): n_violations: int -class RuleTest(unittest.TestCase, metaclass=ABCMeta): +class RuleTest(unittest.IsolatedAsyncioTestCase, metaclass=ABCMeta): """ Abstract class for antispam rule test cases. diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py index e54b4b5b8..d7e779221 100644 --- a/tests/bot/rules/test_attachments.py +++ b/tests/bot/rules/test_attachments.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import attachments from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, total_attachments: int) -> MockMessage: @@ -17,7 +17,6 @@ class AttachmentRuleTests(RuleTest): self.apply = attachments.apply self.config = {"max": 5, "interval": 10} - @async_test async def test_allows_messages_without_too_many_attachments(self): """Messages without too many attachments are allowed as-is.""" cases = ( @@ -28,7 +27,6 @@ class AttachmentRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_with_too_many_attachments(self): """Messages with too many attachments trigger the rule.""" cases = ( diff --git a/tests/bot/rules/test_burst.py b/tests/bot/rules/test_burst.py index 72f0be0c7..03682966b 100644 --- a/tests/bot/rules/test_burst.py +++ b/tests/bot/rules/test_burst.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import burst from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str) -> MockMessage: @@ -21,7 +21,6 @@ class BurstRuleTests(RuleTest): self.apply = burst.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases which do not violate the rule.""" cases = ( @@ -31,7 +30,6 @@ class BurstRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the amount of messages exceeds the limit, triggering the rule.""" cases = ( diff --git a/tests/bot/rules/test_burst_shared.py b/tests/bot/rules/test_burst_shared.py index 47367a5f8..3275143d5 100644 --- a/tests/bot/rules/test_burst_shared.py +++ b/tests/bot/rules/test_burst_shared.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import burst_shared from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str) -> MockMessage: @@ -21,7 +21,6 @@ class BurstSharedRuleTests(RuleTest): self.apply = burst_shared.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """ Cases that do not violate the rule. @@ -34,7 +33,6 @@ class BurstSharedRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the amount of messages exceeds the limit, triggering the rule.""" cases = ( diff --git a/tests/bot/rules/test_chars.py b/tests/bot/rules/test_chars.py index 7cc36f49e..f1e3c76a7 100644 --- a/tests/bot/rules/test_chars.py +++ b/tests/bot/rules/test_chars.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import chars from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, n_chars: int) -> MockMessage: @@ -20,7 +20,6 @@ class CharsRuleTests(RuleTest): "interval": 10, } - @async_test async def test_allows_messages_within_limit(self): """Cases with a total amount of chars within limit.""" cases = ( @@ -31,7 +30,6 @@ class CharsRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases where the total amount of chars exceeds the limit, triggering the rule.""" cases = ( diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py index 0239b0b00..9a72723e2 100644 --- a/tests/bot/rules/test_discord_emojis.py +++ b/tests/bot/rules/test_discord_emojis.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import discord_emojis from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage discord_emoji = "<:abcd:1234>" # Discord emojis follow the format <:name:id> @@ -19,7 +19,6 @@ class DiscordEmojisRuleTests(RuleTest): self.apply = discord_emojis.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases with a total amount of discord emojis within limit.""" cases = ( @@ -29,7 +28,6 @@ class DiscordEmojisRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases with more than the allowed amount of discord emojis.""" cases = ( diff --git a/tests/bot/rules/test_duplicates.py b/tests/bot/rules/test_duplicates.py index 59e0fb6ef..9bd886a77 100644 --- a/tests/bot/rules/test_duplicates.py +++ b/tests/bot/rules/test_duplicates.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import duplicates from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, content: str) -> MockMessage: @@ -17,7 +17,6 @@ class DuplicatesRuleTests(RuleTest): self.apply = duplicates.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases which do not violate the rule.""" cases = ( @@ -28,7 +27,6 @@ class DuplicatesRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases with too many duplicate messages from the same author.""" cases = ( diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py index 3c3f90e5f..b091bd9d7 100644 --- a/tests/bot/rules/test_links.py +++ b/tests/bot/rules/test_links.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import links from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, total_links: int) -> MockMessage: @@ -21,7 +21,6 @@ class LinksTests(RuleTest): "interval": 10 } - @async_test async def test_links_within_limit(self): """Messages with an allowed amount of links.""" cases = ( @@ -34,7 +33,6 @@ class LinksTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_links_exceeding_limit(self): """Messages with a a higher than allowed amount of links.""" cases = ( diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index ebcdabac6..6444532f2 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, total_mentions: int) -> MockMessage: @@ -20,7 +20,6 @@ class TestMentions(RuleTest): "interval": 10, } - @async_test async def test_mentions_within_limit(self): """Messages with an allowed amount of mentions.""" cases = ( @@ -32,7 +31,6 @@ class TestMentions(RuleTest): await self.run_allowed(cases) - @async_test async def test_mentions_exceeding_limit(self): """Messages with a higher than allowed amount of mentions.""" cases = ( diff --git a/tests/bot/rules/test_newlines.py b/tests/bot/rules/test_newlines.py index d61c4609d..e35377773 100644 --- a/tests/bot/rules/test_newlines.py +++ b/tests/bot/rules/test_newlines.py @@ -2,7 +2,7 @@ from typing import Iterable, List from bot.rules import newlines from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, newline_groups: List[int]) -> MockMessage: @@ -29,7 +29,6 @@ class TotalNewlinesRuleTests(RuleTest): "interval": 10, } - @async_test async def test_allows_messages_within_limit(self): """Cases which do not violate the rule.""" cases = ( @@ -41,7 +40,6 @@ class TotalNewlinesRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_total(self): """Cases which violate the rule by having too many newlines in total.""" cases = ( @@ -79,7 +77,6 @@ class GroupNewlinesRuleTests(RuleTest): self.apply = newlines.apply self.config = {"max": 5, "max_consecutive": 3, "interval": 10} - @async_test async def test_disallows_messages_consecutive(self): """Cases which violate the rule due to having too many consecutive newlines.""" cases = ( diff --git a/tests/bot/rules/test_role_mentions.py b/tests/bot/rules/test_role_mentions.py index b339cccf7..26c05d527 100644 --- a/tests/bot/rules/test_role_mentions.py +++ b/tests/bot/rules/test_role_mentions.py @@ -2,7 +2,7 @@ from typing import Iterable from bot.rules import role_mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage, async_test +from tests.helpers import MockMessage def make_msg(author: str, n_mentions: int) -> MockMessage: @@ -17,7 +17,6 @@ class RoleMentionsRuleTests(RuleTest): self.apply = role_mentions.apply self.config = {"max": 2, "interval": 10} - @async_test async def test_allows_messages_within_limit(self): """Cases with a total amount of role mentions within limit.""" cases = ( @@ -27,7 +26,6 @@ class RoleMentionsRuleTests(RuleTest): await self.run_allowed(cases) - @async_test async def test_disallows_messages_beyond_limit(self): """Cases with more than the allowed amount of role mentions.""" cases = ( diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index bdfcc73e4..99e942813 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -2,10 +2,9 @@ import unittest from unittest.mock import MagicMock from bot import api -from tests.helpers import async_test -class APIClientTests(unittest.TestCase): +class APIClientTests(unittest.IsolatedAsyncioTestCase): """Tests for the bot's API client.""" @classmethod @@ -18,7 +17,6 @@ class APIClientTests(unittest.TestCase): """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()) diff --git a/tests/helpers.py b/tests/helpers.py index 5df796c23..01752a791 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,8 +1,6 @@ from __future__ import annotations -import asyncio import collections -import functools import inspect import itertools import logging @@ -25,21 +23,6 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) -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 - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 7894e104a..fe39df308 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -395,11 +395,3 @@ class MockObjectTests(unittest.TestCase): coroutine = async_mock() self.assertTrue(inspect.iscoroutine(coroutine)) self.assertIsNotNone(asyncio.run(coroutine)) - - def test_async_test_decorator_allows_synchronous_call_to_async_def(self): - """Test if the `async_test` decorator allows an `async def` to be called synchronously.""" - @helpers.async_test - async def kosayoda(): - return "return value" - - self.assertEqual(kosayoda(), "return value") -- cgit v1.2.3 From b6500eb967ae4856d4d65d7946b1e341c093eedd Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 13:41:27 +0100 Subject: Remove lingering pytest test_time.py file I forgot to remove one pytest test file during the migration from pytest to unittest. Since we have sinced added a unittest version of the same file, I've now removed the lingering pytest file. --- tests/utils/test_time.py | 62 ------------------------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 tests/utils/test_time.py (limited to 'tests') diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py deleted file mode 100644 index 4baa6395c..000000000 --- a/tests/utils/test_time.py +++ /dev/null @@ -1,62 +0,0 @@ -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 - - -@pytest.mark.parametrize( - ('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 - - -@pytest.mark.parametrize('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) - - -@pytest.mark.parametrize( - ('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) -- cgit v1.2.3 From ea64d7cc6defa759fc1c7f1631a7ae9b8073cc29 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 20:53:45 +0100 Subject: Use unittest's AsyncMock instead of our AsyncMock Python 3.8 introduced an `unittest.mock.AsyncMock` class that can be used to mock coroutines and other types of asynchronous operations like async iterators and async context managers. As we were using our custom, but limited, AsyncMock, I have replaced our mock with unittest's AsyncMock. Since Python 3.8 also introduces a different way of automatically detecting which attributes should be mocked with an AsyncMock, I've changed our CustomMockMixin to use this new method as well. Together with a couple other small changes, this means that our Custom Mocks now use a lazy method of detecting coroutine attributes, which significantly speeds up the test suite. --- tests/bot/cogs/test_duck_pond.py | 22 ++-- tests/bot/cogs/test_information.py | 34 +++---- tests/bot/cogs/test_token_remover.py | 4 +- tests/bot/utils/test_time.py | 3 +- tests/helpers.py | 190 ++++++++++++----------------------- tests/test_helpers.py | 63 ++---------- 6 files changed, 103 insertions(+), 213 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index 6406f0737..e164f7544 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -2,7 +2,7 @@ import asyncio import logging import typing import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import discord @@ -293,8 +293,8 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): ) for message, expect_webhook_call, expect_attachment_call in test_values: - with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook: - with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments: + with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook: + with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments: with self.subTest(clean_content=message.clean_content, attachments=message.attachments): await self.cog.relay_message(message) @@ -303,7 +303,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): message.add_reaction.assert_called_once_with(self.checkmark_emoji) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -314,15 +314,15 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): for side_effect in side_effects: send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) as send_webhook: + with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): with self.assertNotLogs(logger=log, level=logging.ERROR): await self.cog.relay_message(message) self.assertEqual(send_webhook.call_count, 2) - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) - @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" message = helpers.MockMessage(clean_content="message", attachments=["attachment"]) @@ -456,7 +456,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): channel.fetch_message.reset_mock() @patch(f"{MODULE_PATH}.DuckPond.is_staff") - @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) + @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff): """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot.""" channel_id = 31415926535 @@ -491,8 +491,8 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): payload.emoji = self.duck_pond_emoji for duck_count, should_relay in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=helpers.AsyncMock) as relay_message: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: count_ducks.return_value = duck_count with self.subTest(duck_count=duck_count, should_relay=should_relay): await self.cog.on_raw_reaction_add(payload) @@ -526,7 +526,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): (constants.DuckPond.threshold + 1, True), ) for duck_count, should_re_add_checkmark in test_cases: - with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks: + with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks: count_ducks.return_value = duck_count with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark): await self.cog.on_raw_reaction_remove(payload) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index deae7ebad..f5e937356 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -34,7 +34,7 @@ class InformationCogTests(unittest.TestCase): """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 = helpers.AsyncMock() + self.cog.roles_info.can_run = unittest.mock.AsyncMock() self.cog.roles_info.can_run.return_value = True coroutine = self.cog.roles_info.callback(self.cog, self.ctx) @@ -72,7 +72,7 @@ class InformationCogTests(unittest.TestCase): self.ctx.guild.roles.append([dummy_role, admin_role]) - self.cog.role_info.can_run = helpers.AsyncMock() + self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) @@ -174,7 +174,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): def setUp(self): """Common set-up steps done before for each test.""" self.bot = helpers.MockBot() - self.bot.api_client.get = helpers.AsyncMock() + self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) self.member = helpers.MockMember(id=1234) @@ -345,10 +345,10 @@ class UserEmbedTests(unittest.TestCase): def setUp(self): """Common set-up steps done before for each test.""" self.bot = helpers.MockBot() - self.bot.api_client.get = helpers.AsyncMock() + self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -360,7 +360,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -372,7 +372,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -387,8 +387,8 @@ class UserEmbedTests(unittest.TestCase): self.assertIn("&Admins", embed.description) self.assertNotIn("&Everyone", embed.description) - @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock) - @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): """The embed should contain expanded infractions and nomination info in mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) @@ -423,7 +423,7 @@ class UserEmbedTests(unittest.TestCase): embed.description ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): """The embed should contain only basic infraction data outside of mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) @@ -454,7 +454,7 @@ class UserEmbedTests(unittest.TestCase): embed.description ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -467,7 +467,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -477,7 +477,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value="")) + @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() @@ -529,7 +529,7 @@ class UserCommandTests(unittest.TestCase): with self.assertRaises(InChannelCheckFailure, msg=msg): asyncio.run(self.cog.user_info.callback(self.cog, ctx)) - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] @@ -542,7 +542,7 @@ class UserCommandTests(unittest.TestCase): create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] @@ -555,7 +555,7 @@ class UserCommandTests(unittest.TestCase): create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] @@ -568,7 +568,7 @@ class UserCommandTests(unittest.TestCase): create_embed.assert_called_once_with(ctx, self.moderator) ctx.send.assert_called_once() - @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock) + @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) def test_moderators_can_target_another_member(self, create_embed, constants): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index a54b839d7..33d1ec170 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -1,7 +1,7 @@ import asyncio import logging import unittest -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from discord import Colour @@ -11,7 +11,7 @@ from bot.cogs.token_remover import ( setup as setup_cog, ) from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock, MockBot, MockMessage +from tests.helpers import MockBot, MockMessage class TokenRemoverTests(unittest.TestCase): diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 69f35f2f5..de5724bca 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -1,12 +1,11 @@ import asyncio import unittest from datetime import datetime, timezone -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from dateutil.relativedelta import relativedelta from bot.utils import time -from tests.helpers import AsyncMock class TimeTests(unittest.TestCase): diff --git a/tests/helpers.py b/tests/helpers.py index 01752a791..506fe9894 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,11 +1,10 @@ from __future__ import annotations import collections -import inspect import itertools import logging import unittest.mock -from typing import Any, Iterable, Optional +from typing import Iterable, Optional import discord from discord.ext.commands import Context @@ -51,24 +50,31 @@ class CustomMockMixin: """ Provides common functionality for our custom Mock types. - The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine - function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care - of making sure child mocks are instantiated with the correct class. By default, the mock of the - children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute - `child_mock_type` on the custom mock inheriting from this mixin. + The `_get_child_mock` method automatically returns an AsyncMock for coroutine methods of the mock + object. As discord.py also uses synchronous methods that nonetheless return coroutine objects, the + class attribute `additional_spec_asyncs` can be overwritten with an iterable containing additional + attribute names that should also mocked with an AsyncMock instead of a regular MagicMock/Mock. The + class method `spec_set` can be overwritten with the object that should be uses as the specification + for the mock. + + Mock/MagicMock subclasses that use this mixin only need to define `__init__` method if they need to + implement custom behavior. """ child_mock_type = unittest.mock.MagicMock discord_id = itertools.count(0) + spec_set = None + additional_spec_asyncs = None - def __init__(self, spec_set: Any = None, **kwargs): + def __init__(self, **kwargs): name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. - super().__init__(spec_set=spec_set, **kwargs) + super().__init__(spec_set=self.spec_set, **kwargs) + + if self.additional_spec_asyncs: + self._spec_asyncs.extend(self.additional_spec_asyncs) if name: self.name = name - if spec_set: - self._extract_coroutine_methods_from_spec_instance(spec_set) def _get_child_mock(self, **kw): """ @@ -82,7 +88,16 @@ class CustomMockMixin: This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. """ - klass = self.child_mock_type + _new_name = kw.get("_new_name") + if _new_name in self.__dict__['_spec_asyncs']: + return unittest.mock.AsyncMock(**kw) + + _type = type(self) + if issubclass(_type, unittest.mock.MagicMock) and _new_name in unittest.mock._async_method_magics: + # Any asynchronous magic becomes an AsyncMock + klass = unittest.mock.AsyncMock + else: + klass = self.child_mock_type if self._mock_sealed: attribute = "." + kw["name"] if "name" in kw else "()" @@ -91,95 +106,6 @@ class CustomMockMixin: return klass(**kw) - def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: - """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" - for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): - setattr(self, name, AsyncMock()) - - -# TODO: Remove me in Python 3.8 -class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): - """ - A MagicMock subclass to mock async callables. - - Python 3.8 will introduce an AsyncMock class in the standard library that will have some more - features; this stand-in only overwrites the `__call__` method to an async version. - """ - - async def __call__(self, *args, **kwargs): - return super().__call__(*args, **kwargs) - - -class AsyncIteratorMock: - """ - A class to mock asynchronous iterators. - - This allows async for, which is used in certain Discord.py objects. For example, - an async iterator is returned by the Reaction.users() method. - """ - - def __init__(self, iterable: Iterable = None): - if iterable is None: - iterable = [] - - self.iter = iter(iterable) - self.iterable = iterable - - self.call_count = 0 - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self.iter) - except StopIteration: - raise StopAsyncIteration - - def __call__(self): - """ - Keeps track of the number of times an instance has been called. - - This is useful, since it typically shows that the iterator has actually been used somewhere after we have - instantiated the mock for an attribute that normally returns an iterator when called. - """ - self.call_count += 1 - return self - - @property - def return_value(self): - """Makes `self.iterable` accessible as self.return_value.""" - return self.iterable - - @return_value.setter - def return_value(self, iterable): - """Stores the `return_value` as `self.iterable` and its iterator as `self.iter`.""" - self.iter = iter(iterable) - self.iterable = iterable - - def assert_called(self): - """Asserts if the AsyncIteratorMock instance has been called at least once.""" - if self.call_count == 0: - raise AssertionError("Expected AsyncIteratorMock to have been called.") - - def assert_called_once(self): - """Asserts if the AsyncIteratorMock instance has been called exactly once.""" - if self.call_count != 1: - raise AssertionError( - f"Expected AsyncIteratorMock to have been called once. Called {self.call_count} times." - ) - - def assert_not_called(self): - """Asserts if the AsyncIteratorMock instance has not been called.""" - if self.call_count != 0: - raise AssertionError( - f"Expected AsyncIteratorMock to not have been called once. Called {self.call_count} times." - ) - - def reset_mock(self): - """Resets the call count, but not the return value or iterator.""" - self.call_count = 0 - # Create a guild instance to get a realistic Mock of `discord.Guild` guild_data = { @@ -230,9 +156,11 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ + spec_set = guild_instance + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'members': []} - super().__init__(spec_set=guild_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: @@ -251,9 +179,11 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ + spec_set = role_instance + def __init__(self, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1} - super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if 'mention' not in kwargs: self.mention = f'&{self.name}' @@ -276,9 +206,11 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ + spec_set = member_instance + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False} - super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: @@ -299,9 +231,11 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.User` instances. For more information, see the `MockGuild` docstring. """ + spec_set = user_instance + def __init__(self, **kwargs) -> None: default_kwargs = {'name': 'user', 'id': next(self.discord_id), 'bot': False} - super().__init__(spec_set=user_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if 'mention' not in kwargs: self.mention = f"@{self.name}" @@ -320,14 +254,16 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ + spec_set = bot_instance + additional_spec_asyncs = ("wait_for",) def __init__(self, **kwargs) -> None: - super().__init__(spec_set=bot_instance, **kwargs) + super().__init__(**kwargs) # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and # and should therefore be awaited. (The documentation calls it a coroutine as well, which # is technically incorrect, since it's a regular def.) - self.wait_for = AsyncMock() + # self.wait_for = unittest.mock.AsyncMock() # Since calling `create_task` on our MockBot does not actually schedule the coroutine object # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object @@ -358,10 +294,11 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ + spec_set = channel_instance def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} - super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if 'mention' not in kwargs: self.mention = f"#{self.name}" @@ -400,9 +337,10 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.ext.commands.Context` instances. For more information, see the `MockGuild` docstring. """ + spec_set = context_instance def __init__(self, **kwargs) -> None: - super().__init__(spec_set=context_instance, **kwargs) + super().__init__(**kwargs) self.bot = kwargs.get('bot', MockBot()) self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) @@ -419,8 +357,7 @@ class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Attachment` instances. For more information, see the `MockGuild` docstring. """ - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=attachment_instance, **kwargs) + spec_set = attachment_instance class MockMessage(CustomMockMixin, unittest.mock.MagicMock): @@ -430,10 +367,11 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + spec_set = message_instance def __init__(self, **kwargs) -> None: default_kwargs = {'attachments': []} - super().__init__(spec_set=message_instance, **collections.ChainMap(kwargs, default_kwargs)) + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) @@ -449,9 +387,10 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Emoji` instances. For more information, see the `MockGuild` docstring. """ + spec_set = emoji_instance def __init__(self, **kwargs) -> None: - super().__init__(spec_set=emoji_instance, **kwargs) + super().__init__(**kwargs) self.guild = kwargs.get('guild', MockGuild()) @@ -465,9 +404,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For more information, see the `MockGuild` docstring. """ - - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=partial_emoji_instance, **kwargs) + spec_set = partial_emoji_instance reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) @@ -480,12 +417,17 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Reaction` instances. For more information, see the `MockGuild` docstring. """ + spec_set = reaction_instance def __init__(self, **kwargs) -> None: - super().__init__(spec_set=reaction_instance, **kwargs) + _users = kwargs.pop("users", []) + super().__init__(**kwargs) self.emoji = kwargs.get('emoji', MockEmoji()) self.message = kwargs.get('message', MockMessage()) - self.users = AsyncIteratorMock(kwargs.get('users', [])) + + user_iterator = unittest.mock.AsyncMock() + user_iterator.__aiter__.return_value = _users + self.users.return_value = user_iterator webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) @@ -498,13 +440,5 @@ class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Webhook` instances. For more information, see the `MockGuild` docstring. """ - - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=webhook_instance, **kwargs) - - # Because Webhooks can also use a synchronous "WebhookAdapter", the methods are not defined - # as coroutines. That's why we need to set the methods manually. - self.send = AsyncMock() - self.edit = AsyncMock() - self.delete = AsyncMock() - self.execute = AsyncMock() + spec_set = webhook_instance + additional_spec_asyncs = ("send", "edit", "delete", "execute") diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fe39df308..81285e009 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,5 +1,4 @@ import asyncio -import inspect import unittest import unittest.mock @@ -214,6 +213,11 @@ class DiscordMocksTests(unittest.TestCase): with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): asyncio.run(coroutine_object) + def test_user_mock_uses_explicitly_passed_mention_attribute(self): + """MockUser should use an explicitly passed value for user.mention.""" + user = helpers.MockUser(mention="hello") + self.assertEqual(user.mention, "hello") + class MockObjectTests(unittest.TestCase): """Tests the mock objects and mixins we've defined.""" @@ -341,57 +345,10 @@ class MockObjectTests(unittest.TestCase): attribute = getattr(mock, valid_attribute) self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) - def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): - """Test if all coroutine functions are extracted, but not regular methods or attributes.""" - class CoroutineDonor: - def __init__(self): - self.some_attribute = 'alpha' - - async def first_coroutine(): - """This coroutine function should be extracted.""" - - async def second_coroutine(): - """This coroutine function should be extracted.""" - - def regular_method(): - """This regular function should not be extracted.""" - - class Receiver: + def test_custom_mock_mixin_mocks_async_magic_methods_with_async_mock(self): + """The CustomMockMixin should mock async magic methods with an AsyncMock.""" + class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): pass - donor = CoroutineDonor() - receiver = Receiver() - - helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) - - self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) - self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) - self.assertFalse(hasattr(receiver, 'regular_method')) - self.assertFalse(hasattr(receiver, 'some_attribute')) - - @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) - @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") - def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): - """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" - spec_set = "pydis" - - helpers.CustomMockMixin(spec_set=spec_set) - - extract_method_mock.assert_called_once_with(spec_set) - - @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) - @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") - def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): - """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" - helpers.CustomMockMixin() - - extract_method_mock.assert_not_called() - - def test_async_mock_provides_coroutine_for_dunder_call(self): - """Test if AsyncMock objects have a coroutine for their __call__ method.""" - async_mock = helpers.AsyncMock() - self.assertTrue(inspect.iscoroutinefunction(async_mock.__call__)) - - coroutine = async_mock() - self.assertTrue(inspect.iscoroutine(coroutine)) - self.assertIsNotNone(asyncio.run(coroutine)) + mock = MyMock() + self.assertIsInstance(mock.__aenter__, unittest.mock.AsyncMock) -- cgit v1.2.3 From f67cb7ac61eee86419d10e23e3fd3c66f1f9312e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 23 Feb 2020 20:58:20 +0100 Subject: Fix test_time test and ensure coverage One of the test_time methods did not actually assert the exception message it was trying to detect as the assertion statement was contained within the context manager handling the exception. I've moved it out of the context so it actually runs. I've also added a few `praga: no cover` comments for parts that were artifically lowering coverage of the test suite. --- tests/bot/cogs/test_duck_pond.py | 2 +- tests/bot/rules/__init__.py | 4 ++-- tests/bot/utils/test_time.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index e164f7544..7370b8471 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -312,7 +312,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): self.cog.webhook = helpers.MockAsyncWebhook() log = logging.getLogger("bot.cogs.duck_pond") - for side_effect in side_effects: + for side_effect in side_effects: # pragma: no cover send_attachments.side_effect = side_effect with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py index 0233e7939..0d570f5a3 100644 --- a/tests/bot/rules/__init__.py +++ b/tests/bot/rules/__init__.py @@ -68,9 +68,9 @@ class RuleTest(unittest.IsolatedAsyncioTestCase, metaclass=ABCMeta): @abstractmethod def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: """Give expected relevant messages for `case`.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover @abstractmethod def get_report(self, case: DisallowedCase) -> str: """Give expected error report for `case`.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index de5724bca..694d3a40f 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -43,7 +43,7 @@ class TimeTests(unittest.TestCase): 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') + self.assertEqual(str(error.exception), 'max_units must be positive') def test_parse_rfc1123(self): """Testing parse_rfc1123.""" -- cgit v1.2.3 From a850a32135ef3b454abfe2ad017a46badd73d3c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 11:54:58 -0800 Subject: Constants: rename roles to match their names in the guild --- bot/cogs/defcon.py | 12 ++++++------ bot/cogs/eval.py | 4 ++-- bot/cogs/extensions.py | 2 +- bot/cogs/jams.py | 6 +++--- bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/snekbox.py | 2 +- bot/cogs/tags.py | 2 +- bot/cogs/verification.py | 2 +- bot/constants.py | 22 +++++++++++----------- tests/bot/cogs/test_information.py | 2 +- 10 files changed, 28 insertions(+), 28 deletions(-) (limited to 'tests') diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index a0d8fedd5..c7ea1f2bf 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -69,7 +69,7 @@ class Defcon(Cog): except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") await self.bot.get_channel(Channels.devlog).send( - f"<@&{Roles.admin}> **WARNING**: Unable to get DEFCON settings!" + f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!" ) else: @@ -118,7 +118,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.invoke(self.bot.get_command("help"), "defcon") @@ -146,7 +146,7 @@ class Defcon(Cog): await self.send_defcon_log(action, ctx.author, error) @defcon_group.command(name='enable', aliases=('on', 'e')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -159,7 +159,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False @@ -167,7 +167,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -179,7 +179,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 9c729f28a..52136fc8d 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -174,14 +174,14 @@ async def func(): # (None,) -> Any await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) - @with_role(Roles.owner, Roles.admin) + @with_role(Roles.owners, Roles.admins) async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: await ctx.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def eval(self, ctx: Context, *, code: str) -> None: """Run eval in a REPL-like format.""" code = code.strip("`") diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index f16e79fb7..b312e1a1d 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -221,7 +221,7 @@ class Extensions(commands.Cog): # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators and core developers to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developer) + return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 985f28ce5..1d062b0c2 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -18,7 +18,7 @@ class CodeJams(commands.Cog): self.bot = bot @commands.command() - @with_role(Roles.admin) + @with_role(Roles.admins) async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: """ Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. @@ -95,10 +95,10 @@ class CodeJams(commands.Cog): ) # Assign team leader role - await members[0].add_roles(ctx.guild.get_role(Roles.team_leader)) + await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders)) # Assign rest of roles - jammer_role = ctx.guild.get_role(Roles.jammer) + jammer_role = ctx.guild.get_role(Roles.jammers) for member in members: await member.add_roles(jammer_role) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index c0de0e4da..db1a3030e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -307,7 +307,7 @@ class InfractionScheduler(Scheduler): Infractions of unsupported types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) - mod_role = guild.get_role(constants.Roles.moderator) + mod_role = guild.get_role(constants.Roles.moderators) user_id = infraction["user"] actor = infraction["actor"] type_ = infraction["type"] diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index da33e27b2..84457e38f 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -34,7 +34,7 @@ RAW_CODE_REGEX = re.compile( ) MAX_PASTE_LEN = 1000 -EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners) +EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) class Snekbox(Cog): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a38f5617f..2c4fa02bd 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -220,7 +220,7 @@ class Tags(Cog): )) @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(Roles.admin, Roles.owner) + @with_role(Roles.admins, Roles.owners) async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: """Remove a tag from the database.""" await self.bot.api_client.delete(f'bot/tags/{tag_name}') diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 582237374..09bef80c4 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -38,7 +38,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! PERIODIC_PING = ( f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." - f" If you encounter any problems during the verification process, ping the <@&{Roles.admin}> role in this channel." + f" If you encounter any problems during the verification process, ping the <@&{Roles.admins}> role in this channel." ) BOT_MESSAGE_DELETE_DELAY = 10 diff --git a/bot/constants.py b/bot/constants.py index 15f078cbf..03578fefd 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -409,19 +409,19 @@ class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" - admin: int + admins: int announcements: int - champion: int - contributor: int - core_developer: int + code_jam_champions: int + contributors: int + core_developers: int helpers: int - jammer: int - moderator: int + jammers: int + moderators: int muted: int - owner: int + owners: int partners: int - rockstars: int - team_leader: int + python_community: int + team_leaders: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. @@ -570,8 +570,8 @@ BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations -MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner -STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner +MODERATION_ROLES = Roles.moderators, Roles.admins, Roles.owners +STAFF_ROLES = Roles.helpers, Roles.moderators, Roles.admins, Roles.owners # Roles combinations STAFF_CHANNELS = Guild.staff_channels diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index deae7ebad..38293269f 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -19,7 +19,7 @@ class InformationCogTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator) + cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderators) def setUp(self): """Sets up fresh objects for each test.""" -- cgit v1.2.3 From 12c7d2794e8e9d086f5d52c8916df26bb9de5979 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 13:29:08 -0800 Subject: Tests: fix setting bot-commands ID in information tests --- tests/bot/cogs/test_information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 38293269f..8443cfe71 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -521,7 +521,7 @@ class UserCommandTests(unittest.TestCase): """A regular user should not be able to use this command outside of bot-commands.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) @@ -533,7 +533,7 @@ class UserCommandTests(unittest.TestCase): def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) @@ -546,7 +546,7 @@ class UserCommandTests(unittest.TestCase): def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) @@ -559,7 +559,7 @@ class UserCommandTests(unittest.TestCase): def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] - constants.Channels.bot = 50 + constants.Channels.bot_commands = 50 ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) -- cgit v1.2.3 From c7ffafeedc44fde40e3bd5dae6c95fbabc75a9d9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 02:10:22 +0100 Subject: Use realistic mixin implementation Instead of using the mixin class bare, I've now included into a class tha subclasses unittest.TestCase as that's how it's going to be used "in the wild". --- tests/test_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/test_base.py b/tests/test_base.py index 23abb1dfd..235a2ee6c 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -6,7 +6,7 @@ import unittest.mock from tests.base import LoggingTestsMixin, _CaptureLogHandler -class LoggingTestCase(LoggingTestsMixin): +class LoggingTestCase(LoggingTestsMixin, unittest.TestCase): pass -- cgit v1.2.3 From b8bd18bd743608ddff47064d0b459edff3da65e3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 02:12:02 +0100 Subject: Migrate syncers test suite to Python 3.8 The test suite for the new role/member syncers used the "old"-style test suite with the helpers implemented for Python 3.7. I have migrated it to use the new Python 3.8 asyncio test helpers. --- tests/base.py | 4 ++-- tests/bot/cogs/sync/test_base.py | 45 ++++++++++++++++----------------------- tests/bot/cogs/sync/test_cog.py | 31 ++++++++------------------- tests/bot/cogs/sync/test_roles.py | 12 ++--------- tests/bot/cogs/sync/test_users.py | 13 ++--------- tests/helpers.py | 4 +--- 6 files changed, 34 insertions(+), 75 deletions(-) (limited to 'tests') diff --git a/tests/base.py b/tests/base.py index 21613110e..42174e911 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,4 +1,5 @@ import logging +import unittest from contextlib import contextmanager from typing import Dict @@ -77,10 +78,9 @@ class LoggingTestsMixin: self.fail(msg) -class CommandTestCase(unittest.TestCase): +class CommandTestCase(unittest.IsolatedAsyncioTestCase): """TestCase with additional assertions that are useful for testing Discord commands.""" - @helpers.async_test async def assertHasPermissionsCheck( self, cmd: commands.Command, diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e6a6f9688..17aa4198b 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -13,8 +13,8 @@ class TestSyncer(Syncer): """Syncer subclass with mocks for abstract methods for testing purposes.""" name = "test" - _get_diff = helpers.AsyncMock() - _sync = helpers.AsyncMock() + _get_diff = mock.AsyncMock() + _sync = mock.AsyncMock() class SyncerBaseTests(unittest.TestCase): @@ -29,7 +29,7 @@ class SyncerBaseTests(unittest.TestCase): Syncer(self.bot) -class SyncerSendPromptTests(unittest.TestCase): +class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): """Tests for sending the sync confirmation prompt.""" def setUp(self): @@ -61,7 +61,6 @@ class SyncerSendPromptTests(unittest.TestCase): return mock_channel, mock_message - @helpers.async_test async def test_send_prompt_edits_and_returns_message(self): """The given message should be edited to display the prompt and then should be returned.""" msg = helpers.MockMessage() @@ -71,7 +70,6 @@ class SyncerSendPromptTests(unittest.TestCase): self.assertIn("content", msg.edit.call_args[1]) self.assertEqual(ret_val, msg) - @helpers.async_test async def test_send_prompt_gets_dev_core_channel(self): """The dev-core channel should be retrieved if an extant message isn't given.""" subtests = ( @@ -86,7 +84,6 @@ class SyncerSendPromptTests(unittest.TestCase): method.assert_called_once_with(constants.Channels.devcore) - @helpers.async_test async def test_send_prompt_returns_None_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" self.bot.get_channel.return_value = None @@ -96,7 +93,6 @@ class SyncerSendPromptTests(unittest.TestCase): self.assertIsNone(ret_val) - @helpers.async_test async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): """A new message mentioning core devs should be sent and returned if message isn't given.""" for mock_ in (self.mock_get_channel, self.mock_fetch_channel): @@ -108,7 +104,6 @@ class SyncerSendPromptTests(unittest.TestCase): self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) self.assertEqual(ret_val, mock_message) - @helpers.async_test async def test_send_prompt_adds_reactions(self): """The message should have reactions for confirmation added.""" extant_message = helpers.MockMessage() @@ -129,7 +124,7 @@ class SyncerSendPromptTests(unittest.TestCase): mock_message.add_reaction.assert_has_calls(calls) -class SyncerConfirmationTests(unittest.TestCase): +class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): """Tests for waiting for a sync confirmation reaction on the prompt.""" def setUp(self): @@ -211,7 +206,6 @@ class SyncerConfirmationTests(unittest.TestCase): ret_val = self.syncer._reaction_check(*args) self.assertFalse(ret_val) - @helpers.async_test async def test_wait_for_confirmation(self): """The message should always be edited and only return True if the emoji is a check mark.""" subtests = ( @@ -251,14 +245,13 @@ class SyncerConfirmationTests(unittest.TestCase): self.assertIs(actual_return, ret_val) -class SyncerSyncTests(unittest.TestCase): +class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for main function orchestrating the sync.""" def setUp(self): self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) - @helpers.async_test async def test_sync_respects_confirmation_result(self): """The sync should abort if confirmation fails and continue if confirmed.""" mock_message = helpers.MockMessage() @@ -274,7 +267,7 @@ class SyncerSyncTests(unittest.TestCase): diff = _Diff({1, 2, 3}, {4, 5}, None) self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = helpers.AsyncMock( + self.syncer._get_confirmation_result = mock.AsyncMock( return_value=(confirmed, message) ) @@ -289,7 +282,6 @@ class SyncerSyncTests(unittest.TestCase): else: self.syncer._sync.assert_not_called() - @helpers.async_test async def test_sync_diff_size(self): """The diff size should be correctly calculated.""" subtests = ( @@ -303,7 +295,7 @@ class SyncerSyncTests(unittest.TestCase): with self.subTest(size=size, diff=diff): self.syncer._get_diff.reset_mock() self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() await self.syncer.sync(guild) @@ -312,7 +304,6 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._get_confirmation_result.assert_called_once() self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) - @helpers.async_test async def test_sync_message_edited(self): """The message should be edited if one was sent, even if the sync has an API error.""" subtests = ( @@ -324,7 +315,7 @@ class SyncerSyncTests(unittest.TestCase): for message, side_effect, should_edit in subtests: with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = helpers.AsyncMock( + self.syncer._get_confirmation_result = mock.AsyncMock( return_value=(True, message) ) @@ -335,7 +326,6 @@ class SyncerSyncTests(unittest.TestCase): message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - @helpers.async_test async def test_sync_confirmation_context_redirect(self): """If ctx is given, a new message should be sent and author should be ctx's author.""" mock_member = helpers.MockMember() @@ -349,7 +339,10 @@ class SyncerSyncTests(unittest.TestCase): if ctx is not None: ctx.send.return_value = message - self.syncer._get_confirmation_result = helpers.AsyncMock(return_value=(False, None)) + diff = _Diff({1, 2, 3}, {4, 5}, None) + self.syncer._get_diff.return_value = diff + + self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) guild = helpers.MockGuild() await self.syncer.sync(guild, ctx) @@ -362,16 +355,15 @@ class SyncerSyncTests(unittest.TestCase): self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) @mock.patch.object(constants.Sync, "max_diff", new=3) - @helpers.async_test async def test_confirmation_result_small_diff(self): """Should always return True and the given message if the diff size is too small.""" author = helpers.MockMember() expected_message = helpers.MockMessage() - for size in (3, 2): + for size in (3, 2): # pragma: no cover with self.subTest(size=size): - self.syncer._send_prompt = helpers.AsyncMock() - self.syncer._wait_for_confirmation = helpers.AsyncMock() + self.syncer._send_prompt = mock.AsyncMock() + self.syncer._wait_for_confirmation = mock.AsyncMock() coro = self.syncer._get_confirmation_result(size, author, expected_message) result, actual_message = await coro @@ -382,7 +374,6 @@ class SyncerSyncTests(unittest.TestCase): self.syncer._wait_for_confirmation.assert_not_called() @mock.patch.object(constants.Sync, "max_diff", new=3) - @helpers.async_test async def test_confirmation_result_large_diff(self): """Should return True if confirmed and False if _send_prompt fails or aborted.""" author = helpers.MockMember() @@ -394,10 +385,10 @@ class SyncerSyncTests(unittest.TestCase): (False, mock_message, False, "aborted"), ) - for expected_result, expected_message, confirmed, msg in subtests: + for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover with self.subTest(msg=msg): - self.syncer._send_prompt = helpers.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = helpers.AsyncMock(return_value=confirmed) + self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) + self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) coro = self.syncer._get_confirmation_result(4, author) actual_result, actual_message = await coro diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 98c9afc0d..8c87c0d6b 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -18,12 +18,13 @@ class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` instances. For more information, see the `MockGuild` docstring. """ + spec_set = Syncer def __init__(self, **kwargs) -> None: - super().__init__(spec_set=Syncer, **kwargs) + super().__init__(**kwargs) -class SyncExtensionTests(unittest.TestCase): +class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): """Tests for the sync extension.""" @staticmethod @@ -34,7 +35,7 @@ class SyncExtensionTests(unittest.TestCase): bot.add_cog.assert_called_once() -class SyncCogTestCase(unittest.TestCase): +class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): """Base class for Sync cog tests. Sets up patches for syncers.""" def setUp(self): @@ -72,13 +73,13 @@ class SyncCogTestCase(unittest.TestCase): class SyncCogTests(SyncCogTestCase): """Tests for the Sync cog.""" - @mock.patch.object(sync.Sync, "sync_guild") + @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock) def test_sync_cog_init(self, sync_guild): """Should instantiate syncers and run a sync for the guild.""" # Reset because a Sync cog was already instantiated in setUp. self.RoleSyncer.reset_mock() self.UserSyncer.reset_mock() - self.bot.loop.create_task.reset_mock() + self.bot.loop.create_task = mock.MagicMock() mock_sync_guild_coro = mock.MagicMock() sync_guild.return_value = mock_sync_guild_coro @@ -90,7 +91,6 @@ class SyncCogTests(SyncCogTestCase): sync_guild.assert_called_once_with() self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - @helpers.async_test async def test_sync_cog_sync_guild(self): """Roles and users should be synced only if a guild is successfully retrieved.""" for guild in (helpers.MockGuild(), None): @@ -126,14 +126,12 @@ class SyncCogTests(SyncCogTestCase): json=updated_information, ) - @helpers.async_test async def test_sync_cog_patch_user(self): """A PATCH request should be sent and 404 errors ignored.""" for side_effect in (None, self.response_error(404)): with self.subTest(side_effect=side_effect): await self.patch_user_helper(side_effect) - @helpers.async_test async def test_sync_cog_patch_user_non_404(self): """A PATCH request should be sent and the error raised if it's not a 404.""" with self.assertRaises(ResponseCodeError): @@ -145,9 +143,8 @@ class SyncCogListenerTests(SyncCogTestCase): def setUp(self): super().setUp() - self.cog.patch_user = helpers.AsyncMock(spec_set=self.cog.patch_user) + self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) - @helpers.async_test async def test_sync_cog_on_guild_role_create(self): """A POST request should be sent with the new role's data.""" self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) @@ -164,7 +161,6 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) - @helpers.async_test async def test_sync_cog_on_guild_role_delete(self): """A DELETE request should be sent.""" self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) @@ -174,7 +170,6 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.delete.assert_called_once_with("bot/roles/99") - @helpers.async_test async def test_sync_cog_on_guild_role_update(self): """A PUT request should be sent if the colour, name, permissions, or position changes.""" self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) @@ -212,7 +207,6 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.put.assert_not_called() - @helpers.async_test async def test_sync_cog_on_member_remove(self): """Member should patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) @@ -225,7 +219,6 @@ class SyncCogListenerTests(SyncCogTestCase): updated_information={"in_guild": False} ) - @helpers.async_test async def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) @@ -240,7 +233,6 @@ class SyncCogListenerTests(SyncCogTestCase): data = {"roles": sorted(role.id for role in after_member.roles)} self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) - @helpers.async_test async def test_sync_cog_on_member_update_other(self): """Members should not be patched if other attributes have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) @@ -262,7 +254,6 @@ class SyncCogListenerTests(SyncCogTestCase): self.cog.patch_user.assert_not_called() - @helpers.async_test async def test_sync_cog_on_user_update(self): """A user should be patched only if the name, discriminator, or avatar changes.""" self.assertTrue(self.cog.on_user_update.__cog_listener__) @@ -341,7 +332,6 @@ class SyncCogListenerTests(SyncCogTestCase): return data - @helpers.async_test async def test_sync_cog_on_member_join(self): """Should PUT user's data or POST it if the user doesn't exist.""" for side_effect in (None, self.response_error(404)): @@ -354,7 +344,6 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.post.assert_not_called() - @helpers.async_test async def test_sync_cog_on_member_join_non_404(self): """ResponseCodeError should be re-raised if status code isn't a 404.""" with self.assertRaises(ResponseCodeError): @@ -366,7 +355,6 @@ class SyncCogListenerTests(SyncCogTestCase): class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" - @helpers.async_test async def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() @@ -374,7 +362,6 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) - @helpers.async_test async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() @@ -382,7 +369,7 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) - def test_commands_require_admin(self): + async def test_commands_require_admin(self): """The sync commands should only run if the author has the administrator permission.""" cmds = ( self.cog.sync_group, @@ -392,4 +379,4 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): for cmd in cmds: with self.subTest(cmd=cmd): - self.assertHasPermissionsCheck(cmd, {"administrator": True}) + await self.assertHasPermissionsCheck(cmd, {"administrator": True}) diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py index 14fb2577a..79eee98f4 100644 --- a/tests/bot/cogs/sync/test_roles.py +++ b/tests/bot/cogs/sync/test_roles.py @@ -18,7 +18,7 @@ def fake_role(**kwargs): return kwargs -class RoleSyncerDiffTests(unittest.TestCase): +class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between roles in the DB and roles in the Guild cache.""" def setUp(self): @@ -39,7 +39,6 @@ class RoleSyncerDiffTests(unittest.TestCase): return guild - @helpers.async_test async def test_empty_diff_for_identical_roles(self): """No differences should be found if the roles in the guild and DB are identical.""" self.bot.api_client.get.return_value = [fake_role()] @@ -50,7 +49,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_updated_roles(self): """Only updated roles should be added to the 'updated' set of the diff.""" updated_role = fake_role(id=41, name="new") @@ -63,7 +61,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_roles(self): """Only new roles should be added to the 'created' set of the diff.""" new_role = fake_role(id=41, name="new") @@ -76,7 +73,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_deleted_roles(self): """Only deleted roles should be added to the 'deleted' set of the diff.""" deleted_role = fake_role(id=61, name="deleted") @@ -89,7 +85,6 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_updated_and_deleted_roles(self): """When roles are added, updated, and removed, all of them are returned properly.""" new = fake_role(id=41, name="new") @@ -109,14 +104,13 @@ class RoleSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) -class RoleSyncerSyncTests(unittest.TestCase): +class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync roles.""" def setUp(self): self.bot = helpers.MockBot() self.syncer = RoleSyncer(self.bot) - @helpers.async_test async def test_sync_created_roles(self): """Only POST requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] @@ -132,7 +126,6 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - @helpers.async_test async def test_sync_updated_roles(self): """Only PUT requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] @@ -148,7 +141,6 @@ class RoleSyncerSyncTests(unittest.TestCase): self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() - @helpers.async_test async def test_sync_deleted_roles(self): """Only DELETE requests should be made with the correct payload.""" roles = [fake_role(id=111), fake_role(id=222)] diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py index 421bf6bb6..818883012 100644 --- a/tests/bot/cogs/sync/test_users.py +++ b/tests/bot/cogs/sync/test_users.py @@ -17,7 +17,7 @@ def fake_user(**kwargs): return kwargs -class UserSyncerDiffTests(unittest.TestCase): +class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" def setUp(self): @@ -42,7 +42,6 @@ class UserSyncerDiffTests(unittest.TestCase): return guild - @helpers.async_test async def test_empty_diff_for_no_users(self): """When no users are given, an empty diff should be returned.""" guild = self.get_guild() @@ -52,7 +51,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_empty_diff_for_identical_users(self): """No differences should be found if the users in the guild and DB are identical.""" self.bot.api_client.get.return_value = [fake_user()] @@ -63,7 +61,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") @@ -76,7 +73,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_users(self): """Only new users should be added to the 'created' set of the diff.""" new_user = fake_user(id=99, name="new") @@ -89,7 +85,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" leaving_user = fake_user(id=63, in_guild=False) @@ -102,7 +97,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_diff_for_new_updated_and_leaving_users(self): """When users are added, updated, and removed, all of them are returned properly.""" new_user = fake_user(id=99, name="new") @@ -117,7 +111,6 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) - @helpers.async_test async def test_empty_diff_for_db_users_not_in_guild(self): """When the DB knows a user the guild doesn't, no difference is found.""" self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] @@ -129,14 +122,13 @@ class UserSyncerDiffTests(unittest.TestCase): self.assertEqual(actual_diff, expected_diff) -class UserSyncerSyncTests(unittest.TestCase): +class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for the API requests that sync users.""" def setUp(self): self.bot = helpers.MockBot() self.syncer = UserSyncer(self.bot) - @helpers.async_test async def test_sync_created_users(self): """Only POST requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] @@ -152,7 +144,6 @@ class UserSyncerSyncTests(unittest.TestCase): self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() - @helpers.async_test async def test_sync_updated_users(self): """Only PUT requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] diff --git a/tests/helpers.py b/tests/helpers.py index 7ae7ed621..8e13f0f28 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -261,9 +261,7 @@ class MockAPIClient(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `bot.api.APIClient` instances. For more information, see the `MockGuild` docstring. """ - - def __init__(self, **kwargs) -> None: - super().__init__(spec_set=APIClient, **kwargs) + spec_set = APIClient # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` -- cgit v1.2.3 From 0de8f42c122a4bf8f0ea84ea481d2f26d718a0c7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 22:00:34 -0800 Subject: Sync tests: use autospec instead of MockSyncer Autospec supports using AsyncMocks in 3.8 so there's no need to rely on a subclass of CustomMockMixin for the async mocks. --- tests/bot/cogs/sync/test_cog.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 8c87c0d6b..81398c61f 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -11,19 +11,6 @@ from tests import helpers from tests.base import CommandTestCase -class MockSyncer(helpers.CustomMockMixin, mock.MagicMock): - """ - A MagicMock subclass to mock Syncer objects. - - Instances of this class will follow the specifications of `bot.cogs.sync.syncers.Syncer` - instances. For more information, see the `MockGuild` docstring. - """ - spec_set = Syncer - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - - class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): """Tests for the sync extension.""" @@ -41,16 +28,15 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = helpers.MockBot() - # These patch the type. When the type is called, a MockSyncer instanced is returned. - # MockSyncer is needed so that our custom AsyncMock is used. - # TODO: Use autospec instead in 3.8, which will automatically use AsyncMock when needed. self.role_syncer_patcher = mock.patch( "bot.cogs.sync.syncers.RoleSyncer", - new=mock.MagicMock(return_value=MockSyncer()) + autospec=Syncer, + spec_set=True ) self.user_syncer_patcher = mock.patch( "bot.cogs.sync.syncers.UserSyncer", - new=mock.MagicMock(return_value=MockSyncer()) + autospec=Syncer, + spec_set=True ) self.RoleSyncer = self.role_syncer_patcher.start() self.UserSyncer = self.user_syncer_patcher.start() -- cgit v1.2.3 From 3574eaa0c903cd8ed862b8bff896ce0a73412321 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 24 Feb 2020 10:06:09 +0100 Subject: Use MagicMock as return value for _get_diff mock The `_get_diff` method of TestSyncer class is mocked using an AsyncMock object. By default, when an AsyncMock object is called **and awaited**, it returns a child mock of the same time (another AsyncMock) according to the "the child is a like the parent" principle. This means that the _get_diff method will return an AsyncMock unless a different return_value is explicitly provided. Because of that "child is like parent" behavior, this will happen in lines 194-196 of bot.cogs.sync.syncers (annotations added by me): ``` // `diff` will be a child AsyncMock as "child is like parent" diff = await self._get_diff(guild) // `diff._asdict` will be an AsyncMock as "child is like parent" and, // after being called, it will return an unawaited coroutine object // we assign the name `diff_dict`: diff_dict = diff._asdict() // `diff_dict` is still an unawaited coroutine object meaning that it // doesn't have an `items()` method: totals = {k: len(v) for k, v in diff_dict.items() if v is not None} ``` Original, unannotated: https://github.com/python-discord/bot/blob/c81a4d401ea434e98b0a1ece51d3d10f1a3ad226/bot/cogs/sync/syncers.py#L194-L196 This will lead to the following exception when running the tests: ```py ====================================================================== ERROR: test_sync_confirmation_context_redirect (tests.bot.cogs.sync.test_base.SyncerSyncTests) (ctx=None, author=, message=None) If ctx is given, a new message should be sent and author should be ctx's author. ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/sebastiaan/pydis/repositories/bot/tests/bot/cogs/sync/test_base.py", line 348, in test_sync_confirmation_context_redirect await self.syncer.sync(guild, ctx) File "/home/sebastiaan/pydis/repositories/bot/bot/cogs/sync/syncers.py", line 196, in sync totals = {k: len(v) for k, v in diff_dict.items() if v is not None} AttributeError: 'coroutine' object has no attribute 'items' ``` The solution is to assign an explicit return value so the parent mock doesn't "guess" and return an object of its own type. I previously did that by providing a specific `_Diff` object as the return value, but I should have gone with a `MagicMock` to signify that it's not an important return value; it's just something that needs to support/mimic the API we use on it. So that's what this commit adds. --- tests/bot/cogs/sync/test_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 17aa4198b..d17a27409 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -339,8 +339,8 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): if ctx is not None: ctx.send.return_value = message - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) -- cgit v1.2.3 From 6174792c01f238e32aca5cc9222caa4feb788281 Mon Sep 17 00:00:00 2001 From: Numerlor Date: Mon, 24 Feb 2020 19:45:09 +0100 Subject: Remove unused `chunks` function and its tests. The function was only used in the since removed `Events` cog. --- bot/utils/__init__.py | 12 +----------- tests/bot/test_utils.py | 15 --------------- 2 files changed, 1 insertion(+), 26 deletions(-) (limited to 'tests') diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 8184be824..3e4b15ce4 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,5 @@ from abc import ABCMeta -from typing import Any, Generator, Hashable, Iterable +from typing import Any, Hashable from discord.ext.commands import CogMeta @@ -64,13 +64,3 @@ class CaseInsensitiveDict(dict): for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) - - -def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]: - """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks. - - Found: https://stackoverflow.com/a/312464/4022104 - """ - for i in range(0, len(iterable), size): - yield iterable[i:i + size] diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 58ae2a81a..d7bcc3ba6 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -35,18 +35,3 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance = utils.CaseInsensitiveDict() instance.update({'FOO': 'bar'}) self.assertEqual(instance['foo'], 'bar') - - -class ChunkTests(unittest.TestCase): - """Tests the `chunk` method.""" - - def test_empty_chunking(self): - """Tests chunking on an empty iterable.""" - generator = utils.chunks(iterable=[], size=5) - self.assertEqual(list(generator), []) - - def test_list_chunking(self): - """Tests chunking a non-empty list.""" - iterable = [1, 2, 3, 4, 5] - generator = utils.chunks(iterable=iterable, size=2) - self.assertEqual(list(generator), [[1, 2], [3, 4], [5]]) -- cgit v1.2.3 From 284c1de321fea5927dafc1ac3192ad763bda3203 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 25 Feb 2020 12:47:09 -0500 Subject: Fix mismatched constant names in syncer tests --- bot/cogs/sync/syncers.py | 8 ++++---- tests/bot/cogs/sync/test_base.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6715ad6fb..d6891168f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -23,7 +23,7 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developer}> " + _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) def __init__(self, bot: Bot) -> None: @@ -54,12 +54,12 @@ class Syncer(abc.ABC): # Send to core developers if it's an automatic sync. if not message: log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.devcore) + channel = self.bot.get_channel(constants.Channels.dev_core) if not channel: log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") try: - channel = await self.bot.fetch_channel(constants.Channels.devcore) + channel = await self.bot.fetch_channel(constants.Channels.dev_core) except HTTPException: log.exception( f"Failed to fetch channel for sending sync confirmation prompt; " @@ -93,7 +93,7 @@ class Syncer(abc.ABC): `author` of the prompt. """ # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developer == role.id for role in user.roles) + has_role = any(constants.Roles.core_developers == role.id for role in user.roles) return ( reaction.message.id == message.id and not user.bot diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index e6a6f9688..c2e143865 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -84,7 +84,7 @@ class SyncerSendPromptTests(unittest.TestCase): mock_() await self.syncer._send_prompt() - method.assert_called_once_with(constants.Channels.devcore) + method.assert_called_once_with(constants.Channels.dev_core) @helpers.async_test async def test_send_prompt_returns_None_if_channel_fetch_fails(self): @@ -135,7 +135,7 @@ class SyncerConfirmationTests(unittest.TestCase): def setUp(self): self.bot = helpers.MockBot() self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developer) + self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) @staticmethod def get_message_reaction(emoji): -- cgit v1.2.3 From c0d2f51f4e4a57da23f1387e8cf6b1a9e8c02e73 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 26 Feb 2020 19:24:52 +0100 Subject: Adjust tests for new converter behavior. --- tests/bot/test_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index b2b78d9dd..1e5ca62ae 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -68,7 +68,7 @@ class ConverterTests(unittest.TestCase): ('👋', "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."), + ('42', "Tag names must contain at least one letter."), ('x' * 128, "Are you insane? That's way too long!"), ) -- cgit v1.2.3 From f87f7559db8b352490324a535fb77e88f2f68b41 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 27 Feb 2020 11:37:02 +0100 Subject: Split the eval command procedure into two functions. Two functions were created: send_eval and continue_eval, in order to facilitate testing. The corresponding tests are also changed in this commit. --- bot/cogs/snekbox.py | 112 +++++++++++++++++------------- tests/bot/cogs/test_snekbox.py | 150 ++++++++++++++++++++++------------------- 2 files changed, 148 insertions(+), 114 deletions(-) (limited to 'tests') diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index efa4696b5..25b2455e8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -179,6 +179,68 @@ class Snekbox(Cog): return output, paste_link + async def send_eval(self, ctx: Context, code: str) -> Message: + """ + Evaluate code, format it, and send the output to the corresponding channel. + + Return the bot response. + """ + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + icon = self.get_status_emoji(results) + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + return response + + async def continue_eval(self, ctx: Context, response: Message) -> Tuple[bool, Optional[str]]: + """ + Check if the eval session should continue. + + First item of the returned tuple is if the eval session should continue, + the second is the new code to evaluate. + """ + _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) + + try: + _, new_message = await self.bot.wait_for( + 'message_edit', + check=_predicate_eval_message_edit, + timeout=10 + ) + await ctx.message.add_reaction('🔁') + await self.bot.wait_for( + 'reaction_add', + check=_predicate_emoji_reaction, + timeout=10 + ) + + code = new_message.content.split(' ', maxsplit=1)[1] + await ctx.message.clear_reactions() + with contextlib.suppress(HTTPException): + await response.delete() + + except asyncio.TimeoutError: + await ctx.message.clear_reactions() + return False, None + + return True, code + @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) @@ -203,58 +265,18 @@ class Snekbox(Cog): log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - while True: self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) - try: - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) - - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") + response = await self.send_eval(ctx, code) finally: del self.jobs[ctx.author.id] - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=10 - ) - await ctx.message.add_reaction('🔁') - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - log.info(f"Re-evaluating message {ctx.message.id}") - code = new_message.content.split(' ', maxsplit=1)[1] - await ctx.message.clear_reactions() - with contextlib.suppress(HTTPException): - await response.delete() - except asyncio.TimeoutError: - await ctx.message.clear_reactions() - return + continue_eval, code = await self.continue_eval(ctx, response) + if not continue_eval: + break + log.info(f"Re-evaluating message {ctx.message.id}") def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 112c923c8..c1c0f8d47 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,6 +1,7 @@ import asyncio import logging import unittest +from functools import partial from unittest.mock import MagicMock, Mock, call, patch from bot.cogs import snekbox @@ -175,28 +176,33 @@ class SnekboxTests(unittest.TestCase): async def test_eval_command_evaluate_once(self): """Test the eval command procedure.""" ctx = MockContext() - ctx.message = MockMessage() - ctx.send = AsyncMock() - ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) - self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) - self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('[No output]', None)) - self.bot.wait_for.side_effect = asyncio.TimeoutError + response = MockMessage() + self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') + self.cog.send_eval = AsyncMock(return_value=response) + self.cog.continue_eval = AsyncMock(return_value=(False, None)) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') + self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') + self.cog.continue_eval.assert_called_once_with(ctx, response) - ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' - ) - self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) - self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) - self.cog.format_output.assert_called_once_with('') + @async_test + async def test_eval_command_evaluate_twice(self): + """Test the eval and re-eval command procedure.""" + ctx = MockContext() + response = MockMessage() + self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') + self.cog.send_eval = AsyncMock(return_value=response) + self.cog.continue_eval = AsyncMock() + self.cog.continue_eval.side_effect = ((True, 'MyAwesomeCode-2'), (False, None)) + + await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) + self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') + self.cog.continue_eval.assert_called_with(ctx, response) @async_test - async def test_eval_command_reject_two_eval(self): + async def test_eval_command_reject_two_eval_at_the_same_time(self): """Test if the eval command rejects an eval if the author already have a running eval.""" ctx = MockContext() ctx.author.id = 42 @@ -217,92 +223,98 @@ class SnekboxTests(unittest.TestCase): ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") @async_test - async def test_eval_command_return_error(self): - """Test the eval command error handling.""" + async def test_send_eval(self): + """Test the send_eval function.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) - self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Error occurred')) - self.cog.get_status_emoji = MagicMock(return_value=':nope!:') - self.cog.format_output = AsyncMock() - self.bot.wait_for.side_effect = asyncio.TimeoutError - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) + self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) + self.cog.get_status_emoji = MagicMock(return_value=':yay!:') + self.cog.format_output = AsyncMock(return_value=('[No output]', None)) + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nError occurred\n```' + '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) - self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) - self.cog.format_output.assert_not_called() + self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) + self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) + self.cog.format_output.assert_called_once_with('') @async_test - async def test_eval_command_with_paste_link(self): - """Test the eval command procedure with the use of a paste link.""" + async def test_send_eval_with_paste_link(self): + """Test the send_eval function with a too long output that generate a paste link.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': 'SuperLongBeard', 'returncode': 0}) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('Truncated - too long beard', 'https://testificate.com/')) - self.bot.wait_for.side_effect = asyncio.TimeoutError - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com')) + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n' - 'Truncated - too long beard\n```\nFull output: https://testificate.com/' + '@LemonLemonishBeard#0042 :yay!: Return code 0.' + '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') - self.cog.get_status_emoji.assert_called_once_with({'stdout': 'SuperLongBeard', 'returncode': 0}) - self.cog.get_results_message.assert_called_once_with({'stdout': 'SuperLongBeard', 'returncode': 0}) - self.cog.format_output.assert_called_with('SuperLongBeard') + self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) + self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) + self.cog.format_output.assert_called_once_with('Way too long beard') @async_test - async def test_eval_command_evaluate_twice(self): - """Test the eval command re-evaluation procedure.""" + async def test_send_eval_with_non_zero_eval(self): + """Test the send_eval function with a code returning a non-zero code.""" ctx = MockContext() ctx.message = MockMessage() - ctx.message.content = '!e MyAwesomeCode' - updated_msg = MockMessage() - updated_msg .content = '!e MyAwesomeCode-2' - response_msg = MockMessage() - response_msg.delete = AsyncMock() - ctx.send = AsyncMock(return_value=response_msg) + ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) - self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) - self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) - self.cog.get_status_emoji = MagicMock(return_value=':yay!:') - self.cog.format_output = AsyncMock(return_value=('[No output]', None)) - self.bot.wait_for.side_effect = ((None, updated_msg), None, asyncio.TimeoutError) - - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') - - self.cog.post_eval.assert_has_calls((call('MyAwesomeCode'), call('MyAwesomeCode-2'))) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) + self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval')) + self.cog.get_status_emoji = MagicMock(return_value=':nope!:') + self.cog.format_output = AsyncMock() # This function isn't called - # Multiplied by 2 because we expect it to be called twice - ctx.send.assert_has_calls( - [call('@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```')] * 2 + await self.cog.send_eval(ctx, 'MyAwesomeCode') + ctx.send.assert_called_once_with( + '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' ) - self.cog.get_status_emoji.assert_has_calls([call({'stdout': '', 'returncode': 0})] * 2) - self.cog.get_results_message.assert_has_calls([call({'stdout': '', 'returncode': 0})] * 2) - self.cog.format_output.assert_has_calls([call('')] * 2) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') + self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) + self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) + self.cog.format_output.assert_not_called() + @async_test + async def test_continue_eval_does_continue(self): + """Test that the continue_eval function does continue if required conditions are met.""" + ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) + response = MockMessage(delete=AsyncMock()) + new_msg = MockMessage(content='!e NewCode') + self.bot.wait_for.side_effect = ((None, new_msg), None) + + actual = await self.cog.continue_eval(ctx, response) + self.assertEqual(actual, (True, 'NewCode')) self.bot.wait_for.has_calls( - call('message_edit', check=snekbox.predicate_eval_message_edit, timeout=10), - call('reaction_add', check=snekbox.predicate_eval_emoji_reaction, timeout=10) + call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), + call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) ctx.message.add_reaction.assert_called_once_with('🔁') - ctx.message.clear_reactions.assert_called() - response_msg.delete.assert_called_once() + ctx.message.clear_reactions.assert_called_once() + response.delete.assert_called_once() + + @async_test + async def test_continue_eval_does_not_continue(self): + ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock())) + self.bot.wait_for.side_effect = asyncio.TimeoutError + + actual = await self.cog.continue_eval(ctx, MockMessage()) + self.assertEqual(actual, (False, None)) + ctx.message.clear_reactions.assert_called_once() def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" -- cgit v1.2.3 From afc74faadd5fb4d3fd3003bed9f2a1f241c0dc58 Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 27 Feb 2020 11:47:01 +0100 Subject: Use unicode code point instead of literal for the snekbox re-eval emoji Unicode literals aren't really safe compared to code points --- bot/cogs/snekbox.py | 8 +++++--- tests/bot/cogs/test_snekbox.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 25b2455e8..52d830fa8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -42,6 +42,8 @@ EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.ro SIGKILL = 9 +REEVAL_EMOJI = '\U0001f501' # :repeat: + class Snekbox(Cog): """Safe evaluation of Python code using Snekbox.""" @@ -223,7 +225,7 @@ class Snekbox(Cog): check=_predicate_eval_message_edit, timeout=10 ) - await ctx.message.add_reaction('🔁') + await ctx.message.add_reaction(REEVAL_EMOJI) await self.bot.wait_for( 'reaction_add', check=_predicate_emoji_reaction, @@ -285,8 +287,8 @@ def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction 🔁 was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == '🔁' + """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI def setup(bot: Bot) -> None: diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index c1c0f8d47..e7a1e3362 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -303,7 +303,7 @@ class SnekboxTests(unittest.TestCase): call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) - ctx.message.add_reaction.assert_called_once_with('🔁') + ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) ctx.message.clear_reactions.assert_called_once() response.delete.assert_called_once() @@ -336,12 +336,12 @@ class SnekboxTests(unittest.TestCase): def test_predicate_eval_emoji_reaction(self): """Test the predicate_eval_emoji_reaction function.""" valid_reaction = MockReaction(message=MockMessage(id=1)) - valid_reaction.__str__.return_value = '🔁' + valid_reaction.__str__.return_value = snekbox.REEVAL_EMOJI valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) valid_user = MockUser(id=2) invalid_reaction_id = MockReaction(message=MockMessage(id=42)) - invalid_reaction_id.__str__.return_value = '🔁' + invalid_reaction_id.__str__.return_value = snekbox.REEVAL_EMOJI invalid_user_id = MockUser(id=42) invalid_reaction_str = MockReaction(message=MockMessage(id=1)) invalid_reaction_str.__str__.return_value = ':longbeard:' -- cgit v1.2.3 From b62e15f835ff4b6c808a9b571919bcfa479d004b Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 28 Feb 2020 09:41:12 +0100 Subject: Return only the new code in continue_eval and check for truthiness instead --- bot/cogs/snekbox.py | 13 ++++++------- tests/bot/cogs/test_snekbox.py | 8 ++++---- 2 files changed, 10 insertions(+), 11 deletions(-) (limited to 'tests') diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52d830fa8..381b309e0 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -209,12 +209,11 @@ class Snekbox(Cog): log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response - async def continue_eval(self, ctx: Context, response: Message) -> Tuple[bool, Optional[str]]: + async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: """ Check if the eval session should continue. - First item of the returned tuple is if the eval session should continue, - the second is the new code to evaluate. + Return the new code to evaluate or None if the eval session should be terminated. """ _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) @@ -239,9 +238,9 @@ class Snekbox(Cog): except asyncio.TimeoutError: await ctx.message.clear_reactions() - return False, None + return None - return True, code + return code @command(name="eval", aliases=("e",)) @guild_only() @@ -275,8 +274,8 @@ class Snekbox(Cog): finally: del self.jobs[ctx.author.id] - continue_eval, code = await self.continue_eval(ctx, response) - if not continue_eval: + code = await self.continue_eval(ctx, response) + if not code: break log.info(f"Re-evaluating message {ctx.message.id}") diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index e7a1e3362..985bc66a1 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -179,7 +179,7 @@ class SnekboxTests(unittest.TestCase): response = MockMessage() self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') self.cog.send_eval = AsyncMock(return_value=response) - self.cog.continue_eval = AsyncMock(return_value=(False, None)) + self.cog.continue_eval = AsyncMock(return_value=None) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') @@ -194,7 +194,7 @@ class SnekboxTests(unittest.TestCase): self.cog.prepare_input = MagicMock(return_value='MyAwesomeFormattedCode') self.cog.send_eval = AsyncMock(return_value=response) self.cog.continue_eval = AsyncMock() - self.cog.continue_eval.side_effect = ((True, 'MyAwesomeCode-2'), (False, None)) + self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) @@ -298,7 +298,7 @@ class SnekboxTests(unittest.TestCase): self.bot.wait_for.side_effect = ((None, new_msg), None) actual = await self.cog.continue_eval(ctx, response) - self.assertEqual(actual, (True, 'NewCode')) + self.assertEqual(actual, 'NewCode') self.bot.wait_for.has_calls( call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) @@ -313,7 +313,7 @@ class SnekboxTests(unittest.TestCase): self.bot.wait_for.side_effect = asyncio.TimeoutError actual = await self.cog.continue_eval(ctx, MockMessage()) - self.assertEqual(actual, (False, None)) + self.assertEqual(actual, None) ctx.message.clear_reactions.assert_called_once() def test_predicate_eval_message_edit(self): -- cgit v1.2.3 From c2af442676011eb620593505789be4d34da76ea3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 29 Feb 2020 17:06:51 +0100 Subject: Migrate snekbox tests to Python 3.8's unittest I've migrated the `tests/test_snekbox.py` file to use the new Python 3.8-style unittests instead of our old style using our custom Async mocks. In particular, I had to make a few changes: - Mocking the async post() context manager correctly Since `ClientSession.post` returns an async context manager when called, we need to make sure to assign the return value to the __aenter__ method of whatever `post()` returns, not of `post` itself (i.e.. when it's not called). - Use the new AsyncMock assert methods `assert_awaited_once` and `assert_awaited_once_with` Objects of the new `unittest.mock.AsyncMock` class have special methods to assert what they were called with that also assert that specific coroutine object was awaited. This means we test two things in one: Whether or not it was called with the right arguments and whether or not the returned coroutine object was then awaited. - Patch `functools.partial` as `partial` objects are compared by identity When you create two partial functions of the same function, you'll end up with two different `partial` objects. Since `partial` objects are compared by identity, you can't compare a `partial` created in a test method to that created in the callable you're trying to test. They will always compare as `False`. Since we're not interested in actually creating `partial` objects, I've just patched `functools.partial` in the namespace of the module we're testing to make sure we can compare them. --- tests/bot/cogs/test_snekbox.py | 68 +++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 41 deletions(-) (limited to 'tests') diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 985bc66a1..9cd7f0154 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,74 +1,68 @@ import asyncio import logging import unittest -from functools import partial -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch from bot.cogs import snekbox from bot.cogs.snekbox import Snekbox from bot.constants import URLs -from tests.helpers import ( - AsyncContextManagerMock, AsyncMock, MockBot, MockContext, MockMessage, MockReaction, MockUser, async_test -) +from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser -class SnekboxTests(unittest.TestCase): +class SnekboxTests(unittest.IsolatedAsyncioTestCase): def setUp(self): """Add mocked bot and cog to the instance.""" self.bot = MockBot() - - self.mocked_post = MagicMock() - self.mocked_post.json = AsyncMock() - self.bot.http_session.post = MagicMock(return_value=AsyncContextManagerMock(self.mocked_post)) - self.cog = Snekbox(bot=self.bot) - @async_test async def test_post_eval(self): """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - self.mocked_post.json.return_value = {'lemon': 'AI'} + resp = MagicMock() + resp.json = AsyncMock(return_value="return") + self.bot.http_session.post().__aenter__.return_value = resp - self.assertEqual(await self.cog.post_eval("import random"), {'lemon': 'AI'}) - self.bot.http_session.post.assert_called_once_with( + self.assertEqual(await self.cog.post_eval("import random"), "return") + self.bot.http_session.post.assert_called_with( URLs.snekbox_eval_api, json={"input": "import random"}, raise_for_status=True ) + resp.json.assert_awaited_once() - @async_test async def test_upload_output_reject_too_long(self): """Reject output longer than MAX_PASTE_LEN.""" result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) self.assertEqual(result, "too long to upload") - @async_test async def test_upload_output(self): """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "RainbowDash" - self.mocked_post.json.return_value = {"key": key} + key = "MarkDiamond" + resp = MagicMock() + resp.json = AsyncMock(return_value={"key": key}) + self.bot.http_session.post().__aenter__.return_value = resp self.assertEqual( await self.cog.upload_output("My awesome output"), URLs.paste_service.format(key=key) ) - self.bot.http_session.post.assert_called_once_with( + self.bot.http_session.post.assert_called_with( URLs.paste_service.format(key="documents"), data="My awesome output", raise_for_status=True ) - @async_test async def test_upload_output_gracefully_fallback_if_exception_during_request(self): """Output upload gracefully fallback if the upload fail.""" - self.mocked_post.json.side_effect = Exception + resp = MagicMock() + resp.json = AsyncMock(side_effect=Exception) + self.bot.http_session.post().__aenter__.return_value = resp + log = logging.getLogger("bot.cogs.snekbox") with self.assertLogs(logger=log, level='ERROR'): await self.cog.upload_output('My awesome output!') - @async_test async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): """Output upload gracefully fallback if there is no key entry in the response body.""" - self.mocked_post.json.return_value = {} self.assertEqual((await self.cog.upload_output('My awesome output!')), None) def test_prepare_input(self): @@ -121,7 +115,6 @@ class SnekboxTests(unittest.TestCase): actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) self.assertEqual(actual, expected) - @async_test async def test_format_output(self): """Test output formatting.""" self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') @@ -172,7 +165,6 @@ class SnekboxTests(unittest.TestCase): with self.subTest(msg=testname, case=case, expected=expected): self.assertEqual(await self.cog.format_output(case), expected) - @async_test async def test_eval_command_evaluate_once(self): """Test the eval command procedure.""" ctx = MockContext() @@ -186,7 +178,6 @@ class SnekboxTests(unittest.TestCase): self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_once_with(ctx, response) - @async_test async def test_eval_command_evaluate_twice(self): """Test the eval and re-eval command procedure.""" ctx = MockContext() @@ -201,7 +192,6 @@ class SnekboxTests(unittest.TestCase): self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_with(ctx, response) - @async_test async def test_eval_command_reject_two_eval_at_the_same_time(self): """Test if the eval command rejects an eval if the author already have a running eval.""" ctx = MockContext() @@ -214,7 +204,6 @@ class SnekboxTests(unittest.TestCase): "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!" ) - @async_test async def test_eval_command_call_help(self): """Test if the eval command call the help command if no code is provided.""" ctx = MockContext() @@ -222,14 +211,13 @@ class SnekboxTests(unittest.TestCase): await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") - @async_test async def test_send_eval(self): """Test the send_eval function.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) + self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') @@ -244,14 +232,13 @@ class SnekboxTests(unittest.TestCase): self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) self.cog.format_output.assert_called_once_with('') - @async_test async def test_send_eval_with_paste_link(self): """Test the send_eval function with a too long output that generate a paste link.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) + self.cog.post_eval = AsyncMock(return_value={'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) self.cog.get_status_emoji = MagicMock(return_value=':yay!:') @@ -267,14 +254,12 @@ class SnekboxTests(unittest.TestCase): self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) self.cog.format_output.assert_called_once_with('Way too long beard') - @async_test async def test_send_eval_with_non_zero_eval(self): """Test the send_eval function with a code returning a non-zero code.""" ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() ctx.author.mention = '@LemonLemonishBeard#0042' - ctx.typing = MagicMock(return_value=AsyncContextManagerMock(None)) self.cog.post_eval = AsyncMock(return_value={'stdout': 'ERROR', 'returncode': 127}) self.cog.get_results_message = MagicMock(return_value=('Return code 127', 'Beard got stuck in the eval')) self.cog.get_status_emoji = MagicMock(return_value=':nope!:') @@ -289,8 +274,8 @@ class SnekboxTests(unittest.TestCase): self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) self.cog.format_output.assert_not_called() - @async_test - async def test_continue_eval_does_continue(self): + @patch("bot.cogs.snekbox.partial") + async def test_continue_eval_does_continue(self, partial_mock): """Test that the continue_eval function does continue if required conditions are met.""" ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) response = MockMessage(delete=AsyncMock()) @@ -299,15 +284,16 @@ class SnekboxTests(unittest.TestCase): actual = await self.cog.continue_eval(ctx, response) self.assertEqual(actual, 'NewCode') - self.bot.wait_for.has_calls( - call('message_edit', partial(snekbox.predicate_eval_message_edit, ctx), timeout=10), - call('reaction_add', partial(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) + self.bot.wait_for.assert_has_awaits( + ( + call('message_edit', check=partial_mock(snekbox.predicate_eval_message_edit, ctx), timeout=10), + call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) + ) ) ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) ctx.message.clear_reactions.assert_called_once() response.delete.assert_called_once() - @async_test async def test_continue_eval_does_not_continue(self): ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock())) self.bot.wait_for.side_effect = asyncio.TimeoutError -- cgit v1.2.3 From 91c6bcd0dfbaad201ee47af2ee7e36e4f372a115 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 1 Mar 2020 14:27:14 -0500 Subject: Modify log test regex to be non-os-specific Previous regex utilized a `/`, which doesn't work for comparing against Windows paths, which use `\` --- tests/test_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/test_base.py b/tests/test_base.py index 235a2ee6c..a7db4bf3e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -29,7 +29,7 @@ class LoggingTestCaseTests(unittest.TestCase): """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" msg_regex = ( r"1 logs of DEBUG or higher were triggered on root:\n" - r'' + r'' ) with self.assertRaisesRegex(AssertionError, msg_regex): with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): -- cgit v1.2.3 From 28bcbf334eb08dfcd35b898b7cb803338664ee61 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Mar 2020 09:42:00 -0800 Subject: Add more pre-commit hooks * Remove trailing whitespaces * Specify error code for a noqa in the free command --- .pre-commit-config.yaml | 23 ++++++++++++++++++++--- CONTRIBUTING.md | 2 +- bot/cogs/free.py | 2 +- tests/README.md | 10 +++++----- 4 files changed, 27 insertions(+), 10 deletions(-) (limited to 'tests') diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 860357868..4bb5e7e1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,27 @@ +exclude: ^\.cache/|\.venv/|\.git/|htmlcov/|logs/ repos: -- repo: local + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 hooks: - - id: flake8 + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + args: [--unsafe] # Required due to custom constructors (e.g. !ENV) + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.5.1 + hooks: + - id: python-check-blanket-noqa + - repo: local + hooks: + - id: flake8 name: Flake8 description: This hook runs flake8 within our project's pipenv environment. entry: pipenv run lint language: python types: [python] - require_serial: true \ No newline at end of file + require_serial: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39f76c7b4..61d11f844 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ To provide a standalone development environment for this project, docker compose When pulling down changes from GitHub, remember to sync your environment using `pipenv sync --dev` to ensure you're using the most up-to-date versions the project's dependencies. ### Type Hinting -[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. +[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. For example: diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 02c02d067..33b55e79a 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -55,7 +55,7 @@ class Free(Cog): msg = messages[seek - 1] # Otherwise get last message else: - msg = await channel.history(limit=1).next() # noqa (False positive) + msg = await channel.history(limit=1).next() # noqa: B305 inactive = (datetime.utcnow() - msg.created_at).seconds if inactive > TIMEOUT: diff --git a/tests/README.md b/tests/README.md index be78821bf..4f62edd68 100644 --- a/tests/README.md +++ b/tests/README.md @@ -83,7 +83,7 @@ TagContentConverter should return correct values for valid input. As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. -However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". +However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). @@ -114,13 +114,13 @@ class BotCogTests(unittest.TestCase): ### Mocking coroutines -By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. ### Special mocks for some `discord.py` types To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. -In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. @@ -144,7 +144,7 @@ Finally, there are some considerations to make when writing tests, both for writ ### Test coverage is a starting point -Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. +Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. One problem is that 100% branch coverage may be misleading if we haven't tested our code against all the realistic input it may get in production. For instance, take a look at the following `member_information` function and the test we've written for it: @@ -169,7 +169,7 @@ class FunctionsTests(unittest.TestCase): If you were to run this test, not only would the function pass the test, `coverage.py` will also tell us that the test provides 100% branch coverage for the function. Can you spot the bug the test suite did not catch? -The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). +The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). Adding another test would not increase the test coverage we have, but it does ensure that we'll notice that this function can fail with realistic data: -- cgit v1.2.3 From aae928ebc06e7e7a6ed5b5b848464ce95e4ea9d8 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Mar 2020 22:53:19 -0500 Subject: Remove CaseInsensitiveDict This was added by the now-removed Snake cog & is not used elsewhere on bot. --- bot/utils/__init__.py | 57 ------------------------------------------------- tests/bot/test_utils.py | 37 -------------------------------- 2 files changed, 94 deletions(-) delete mode 100644 tests/bot/test_utils.py (limited to 'tests') diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 3e4b15ce4..9b32e515d 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,4 @@ from abc import ABCMeta -from typing import Any, Hashable from discord.ext.commands import CogMeta @@ -8,59 +7,3 @@ class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" pass - - -class CaseInsensitiveDict(dict): - """ - We found this class on StackOverflow. Thanks to m000 for writing it! - - https://stackoverflow.com/a/32888599/4022104 - """ - - @classmethod - def _k(cls, key: Hashable) -> Hashable: - """Return lowered key if a string-like is passed, otherwise pass key straight through.""" - return key.lower() if isinstance(key, str) else key - - def __init__(self, *args, **kwargs): - super(CaseInsensitiveDict, self).__init__(*args, **kwargs) - self._convert_keys() - - def __getitem__(self, key: Hashable) -> Any: - """Case insensitive __setitem__.""" - return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) - - def __setitem__(self, key: Hashable, value: Any): - """Case insensitive __setitem__.""" - super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) - - def __delitem__(self, key: Hashable) -> Any: - """Case insensitive __delitem__.""" - return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) - - def __contains__(self, key: Hashable) -> bool: - """Case insensitive __contains__.""" - return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) - - def pop(self, key: Hashable, *args, **kwargs) -> Any: - """Case insensitive pop.""" - return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) - - def get(self, key: Hashable, *args, **kwargs) -> Any: - """Case insensitive get.""" - return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) - - def setdefault(self, key: Hashable, *args, **kwargs) -> Any: - """Case insensitive setdefault.""" - return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) - - def update(self, E: Any = None, **F) -> None: - """Case insensitive update.""" - super(CaseInsensitiveDict, self).update(self.__class__(E)) - super(CaseInsensitiveDict, self).update(self.__class__(**F)) - - def _convert_keys(self) -> None: - """Helper method to lowercase all existing string-like keys.""" - for k in list(self.keys()): - v = super(CaseInsensitiveDict, self).pop(k) - self.__setitem__(k, v) diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py deleted file mode 100644 index d7bcc3ba6..000000000 --- a/tests/bot/test_utils.py +++ /dev/null @@ -1,37 +0,0 @@ -import unittest - -from bot import utils - - -class CaseInsensitiveDictTests(unittest.TestCase): - """Tests for the `CaseInsensitiveDict` container.""" - - def test_case_insensitive_key_access(self): - """Tests case insensitive key access and storage.""" - instance = utils.CaseInsensitiveDict() - - key = 'LEMON' - value = 'trees' - - instance[key] = value - self.assertIn(key, instance) - self.assertEqual(instance.get(key), value) - self.assertEqual(instance.get(key.casefold()), value) - self.assertEqual(instance.pop(key.casefold()), value) - self.assertNotIn(key, instance) - self.assertNotIn(key.casefold(), instance) - - instance.setdefault(key, value) - del instance[key] - self.assertNotIn(key, instance) - - def test_initialization_from_kwargs(self): - """Tests creating the dictionary from keyword arguments.""" - instance = utils.CaseInsensitiveDict({'FOO': 'bar'}) - self.assertEqual(instance['foo'], 'bar') - - def test_update_from_other_mapping(self): - """Tests updating the dictionary from another mapping.""" - instance = utils.CaseInsensitiveDict() - instance.update({'FOO': 'bar'}) - self.assertEqual(instance['foo'], 'bar') -- cgit v1.2.3 From 2c85b2241bd8a1e7ca8290cd385cded97c54f9bb Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Mar 2020 22:59:07 -0500 Subject: Update code for pep8-naming compliance --- tests/base.py | 4 ++-- tests/bot/cogs/sync/test_base.py | 2 +- tests/bot/cogs/test_snekbox.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) (limited to 'tests') diff --git a/tests/base.py b/tests/base.py index 42174e911..d99b9ac31 100644 --- a/tests/base.py +++ b/tests/base.py @@ -31,7 +31,7 @@ class LoggingTestsMixin: """ @contextmanager - def assertNotLogs(self, logger=None, level=None, msg=None): + def assertNotLogs(self, logger=None, level=None, msg=None): # noqa: N802 """ Asserts that no logs of `level` and higher were emitted by `logger`. @@ -81,7 +81,7 @@ class LoggingTestsMixin: class CommandTestCase(unittest.IsolatedAsyncioTestCase): """TestCase with additional assertions that are useful for testing Discord commands.""" - async def assertHasPermissionsCheck( + async def assertHasPermissionsCheck( # noqa: N802 self, cmd: commands.Command, permissions: Dict[str, bool], diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index fe0594efe..6ee9dfda6 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -84,7 +84,7 @@ class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): method.assert_called_once_with(constants.Channels.dev_core) - async def test_send_prompt_returns_None_if_channel_fetch_fails(self): + async def test_send_prompt_returns_none_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" self.bot.get_channel.return_value = None self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 9cd7f0154..fd9468829 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -89,15 +89,15 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(actual, expected) @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) - def test_get_results_message_invalid_signal(self, mock_Signals: Mock): + def test_get_results_message_invalid_signal(self, mock_signals: Mock): self.assertEqual( self.cog.get_results_message({'stdout': '', 'returncode': 127}), ('Your eval job has completed with return code 127', '') ) @patch('bot.cogs.snekbox.Signals') - def test_get_results_message_valid_signal(self, mock_Signals: Mock): - mock_Signals.return_value.name = 'SIGTEST' + def test_get_results_message_valid_signal(self, mock_signals: Mock): + mock_signals.return_value.name = 'SIGTEST' self.assertEqual( self.cog.get_results_message({'stdout': '', 'returncode': 127}), ('Your eval job has completed with return code 127 (SIGTEST)', '') -- cgit v1.2.3