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 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 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 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 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 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(-) 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(-) 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 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(-) 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(+) 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(-) 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(-) 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 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 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 debe73add8bf5c5f9b33e32201f1ce212758c8e4 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:44:56 +0100 Subject: No longer check if every role is @everyone; just skip the first element in the list --- bot/cogs/information.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 125d7ce24..bc2deae8f 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -32,8 +32,7 @@ class Information(Cog): async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" # Sort the roles alphabetically and remove the @everyone role - roles = sorted(ctx.guild.roles, key=lambda role: role.name) - roles = [role for role in roles if role.name != "@everyone"] + roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) # Build a string role_string = "" @@ -202,7 +201,7 @@ class Information(Cog): name = f"{user.nick} ({name})" joined = time_since(user.joined_at, precision="days") - roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone") + roles = ", ".join(role.mention for role in user.roles[1:]) description = [ textwrap.dedent(f""" -- cgit v1.2.3 From 766331588ebad2ac74ccde572d241803db77f70c Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:45:18 +0100 Subject: Roles cannot return None because everyone has the Developer role by default, and non-verified users cannot use this command. --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index bc2deae8f..21e3cfc39 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -212,7 +212,7 @@ class Information(Cog): {custom_status} **Member Information** Joined: {joined} - Roles: {roles or None} + Roles: {roles} """).strip() ] -- cgit v1.2.3 From 515b490fca1f151e654ff72a9fcf8f3113f239c3 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:46:45 +0100 Subject: Remove some a lot of unneccesary newlines that arguably make it harder to read --- bot/cogs/information.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 21e3cfc39..68614d2c4 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -45,7 +45,6 @@ class Information(Cog): colour=Colour.blurple(), description=role_string ) - embed.set_footer(text=f"Total roles: {len(roles)}") await ctx.send(embed=embed) @@ -75,23 +74,17 @@ class Information(Cog): parsed_roles.append(role) for role in parsed_roles: + h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) + embed = Embed( title=f"{role.name} info", colour=role.colour, ) - embed.add_field(name="ID", value=role.id, inline=True) - embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True) - - h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) - embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) - embed.add_field(name="Member count", value=len(role.members), inline=True) - embed.add_field(name="Position", value=role.position) - embed.add_field(name="Permission code", value=role.permissions.value, inline=True) await ctx.send(embed=embed) -- cgit v1.2.3 From 5f799b68316e03cd0a565af484f7dee3f79ed35e Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:48:26 +0100 Subject: Refactor how channels and statuses are counted; using Counter() - way cleaner. --- bot/cogs/information.py | 52 ++++++++++++++++--------------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 68614d2c4..412447835 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap import typing -from collections import defaultdict +from collections import Counter, defaultdict from typing import Any, Mapping, Optional import discord @@ -96,36 +96,18 @@ class Information(Cog): features = ", ".join(ctx.guild.features) region = ctx.guild.region - # How many of each type of channel? roles = len(ctx.guild.roles) - channels = ctx.guild.channels - text_channels = 0 - category_channels = 0 - voice_channels = 0 - for channel in channels: - if type(channel) == TextChannel: - text_channels += 1 - elif type(channel) == CategoryChannel: - category_channels += 1 - elif type(channel) == VoiceChannel: - voice_channels += 1 + member_count = ctx.guild.member_count + + # How many of each type of channel? + channels = Counter({TextChannel: 0, VoiceChannel: 0, CategoryChannel: 0}) + for channel in ctx.guild.channels: + channels[channel.__class__] += 1 # How many of each user status? - member_count = ctx.guild.member_count - members = ctx.guild.members - online = 0 - dnd = 0 - idle = 0 - offline = 0 - for member in members: - if str(member.status) == "online": - online += 1 - elif str(member.status) == "offline": - offline += 1 - elif str(member.status) == "idle": - idle += 1 - elif str(member.status) == "dnd": - dnd += 1 + statuses = Counter({status.value: 0 for status in Status}) + for member in ctx.guild.members: + statuses[member.status.value] += 1 embed = Embed( colour=Colour.blurple(), @@ -138,15 +120,15 @@ class Information(Cog): **Counts** Members: {member_count:,} Roles: {roles} - Text: {text_channels} - Voice: {voice_channels} - Channel categories: {category_channels} + Text Channels: {channels[TextChannel]} + Voice Channels: {channels[VoiceChannel]} + Channel categories: {channels[CategoryChannel]} **Members** - {constants.Emojis.status_online} {online} - {constants.Emojis.status_idle} {idle} - {constants.Emojis.status_dnd} {dnd} - {constants.Emojis.status_offline} {offline} + {constants.Emojis.status_online} {statuses['online']} + {constants.Emojis.status_idle} {statuses['idle']} + {constants.Emojis.status_dnd} {statuses['dnd']} + {constants.Emojis.status_offline} {statuses['offline']} """) ) -- 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(-) 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 90dd064f2a8cfe66e5cefbe7b679dac38f6f7845 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:50:10 +0100 Subject: Instead of sending a message everytime a role can't be converted, append it to a list, and then send them it at once (less spammy) --- bot/cogs/information.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 412447835..bc67ab5c2 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -58,6 +58,7 @@ class Information(Cog): To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks. """ parsed_roles = [] + failed_roles = [] for role_name in roles: if isinstance(role_name, Role): @@ -68,11 +69,17 @@ class Information(Cog): role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles) if not role: - await ctx.send(f":x: Could not convert `{role_name}` to a role") + failed_roles.append(role_name) continue parsed_roles.append(role) + if failed_roles: + await ctx.send( + ":x: I could not convert the following role names to a role: \n- " + "\n- ".join(failed_roles) + ) + for role in parsed_roles: h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) -- cgit v1.2.3 From 8f1f8055383e5cbf017f4f2cec7074518dab95fd Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 21:59:46 +0100 Subject: Fix up imports a bit; there's no need to import all of discord and typing for just 1 or 2 uses (e.g. Union, and Message). --- bot/cogs/information.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index bc67ab5c2..3b8a88309 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -2,14 +2,11 @@ import colorsys import logging import pprint import textwrap -import typing from collections import Counter, defaultdict -from typing import Any, Mapping, Optional +from typing import Any, Mapping, Optional, Union -import discord -from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, command, group +from discord import CategoryChannel, Colour, Embed, Member, Message, Role, Status, TextChannel, VoiceChannel, utils +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group from discord.utils import escape_markdown from bot import constants @@ -51,7 +48,7 @@ class Information(Cog): @with_role(*constants.MODERATION_ROLES) @command(name="role") - async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None: + async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: """ Return information on a role or list of roles. @@ -337,13 +334,13 @@ class Information(Cog): @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) - async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: + async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling # doing this extra request is also much easier than trying to convert everything back into a dictionary again raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) - paginator = commands.Paginator() + paginator = Paginator() def add_content(title: str, content: str) -> None: paginator.add_line(f'== {title} ==\n') @@ -371,7 +368,7 @@ class Information(Cog): await ctx.send(page) @raw.command() - async def json(self, ctx: Context, message: discord.Message) -> None: + async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From 4653dadbbe929055355892b322e4b0cfd3e09ab6 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 6 Feb 2020 22:00:13 +0100 Subject: Change if statement to elif; if the first if statement returns true, the second cannot be true making it unneccesary to check --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3b8a88309..e1a68ee63 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -147,7 +147,7 @@ class Information(Cog): user = ctx.author # Do a role check if this is being executed on someone other than the caller - if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): + elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return -- 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(-) 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 b0fe5841f58d72a12b3f3ddfd5de7b648770fd58 Mon Sep 17 00:00:00 2001 From: Deniz Date: Sat, 8 Feb 2020 18:48:32 +0100 Subject: Use the enum itself instead of its string value --- bot/cogs/information.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index e1a68ee63..efe660851 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -109,9 +109,9 @@ class Information(Cog): channels[channel.__class__] += 1 # How many of each user status? - statuses = Counter({status.value: 0 for status in Status}) + statuses = Counter({status: 0 for status in Status}) for member in ctx.guild.members: - statuses[member.status.value] += 1 + statuses[member.status] += 1 embed = Embed( colour=Colour.blurple(), @@ -129,10 +129,10 @@ class Information(Cog): Channel categories: {channels[CategoryChannel]} **Members** - {constants.Emojis.status_online} {statuses['online']} - {constants.Emojis.status_idle} {statuses['idle']} - {constants.Emojis.status_dnd} {statuses['dnd']} - {constants.Emojis.status_offline} {statuses['offline']} + {constants.Emojis.status_online} {statuses[Status.online]} + {constants.Emojis.status_idle} {statuses[Status.idle]} + {constants.Emojis.status_dnd} {statuses[Status.dnd]} + {constants.Emojis.status_offline} {statuses[Status.offline]} """) ) -- cgit v1.2.3 From 7806638ffc9b634012f809d1e764ac38c3f58f8e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 20:55:03 -0800 Subject: Bot: add wait_until_guild_available This coroutine waits until the configured guild is available and ensures the cache is present. The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE gateway event before giving up and thus not populating the cache for unavailable guilds. --- bot/bot.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 8f808272f..c0f31911c 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,11 +1,14 @@ +import asyncio import logging import socket from typing import Optional import aiohttp +import discord from discord.ext import commands from bot import api +from bot import constants log = logging.getLogger('bot') @@ -24,6 +27,8 @@ class Bot(commands.Bot): super().__init__(*args, connector=self.connector, **kwargs) + self._guild_available = asyncio.Event() + self.http_session: Optional[aiohttp.ClientSession] = None self.api_client = api.APIClient(loop=self.loop, connector=self.connector) @@ -51,3 +56,37 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self.connector) await super().start(*args, **kwargs) + + async def on_guild_available(self, guild: discord.Guild) -> None: + """ + Set the internal guild available event when constants.Guild.id becomes available. + + If the cache appears to still be empty (no members, no channels, or no roles), the event + will not be set. + """ + if guild.id != constants.Guild.id: + return + + if not guild.roles or not guild.members or not guild.channels: + log.warning( + "Guild available event was dispatched but the cache appears to still be empty!" + ) + return + + self._guild_available.set() + + async def on_guild_unavailable(self, guild: discord.Guild) -> None: + """Clear the internal guild available event when constants.Guild.id becomes unavailable.""" + if guild.id != constants.Guild.id: + return + + self._guild_available.clear() + + async def wait_until_guild_available(self) -> None: + """ + Wait until the constants.Guild.id guild is available (and the cache is ready). + + The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE + gateway event before giving up and thus not populating the cache for unavailable guilds. + """ + await self._guild_available.wait() -- cgit v1.2.3 From c1a86468df6c157343a9a9f0ac69a22c412c6cdf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 20:55:42 -0800 Subject: Bot: make the connector attribute private --- bot/bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index c0f31911c..e5b9717db 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -20,17 +20,17 @@ class Bot(commands.Bot): # Use asyncio for DNS resolution instead of threads so threads aren't spammed. # Use AF_INET as its socket family to prevent HTTPS related problems both locally # and in production. - self.connector = aiohttp.TCPConnector( + self._connector = aiohttp.TCPConnector( resolver=aiohttp.AsyncResolver(), family=socket.AF_INET, ) - super().__init__(*args, connector=self.connector, **kwargs) + super().__init__(*args, connector=self._connector, **kwargs) self._guild_available = asyncio.Event() self.http_session: Optional[aiohttp.ClientSession] = None - self.api_client = api.APIClient(loop=self.loop, connector=self.connector) + self.api_client = api.APIClient(loop=self.loop, connector=self._connector) log.addHandler(api.APILoggingHandler(self.api_client)) @@ -53,7 +53,7 @@ class Bot(commands.Bot): async def start(self, *args, **kwargs) -> None: """Open an aiohttp session before logging in and connecting to Discord.""" - self.http_session = aiohttp.ClientSession(connector=self.connector) + self.http_session = aiohttp.ClientSession(connector=self._connector) await super().start(*args, **kwargs) -- cgit v1.2.3 From 04d8e1410d8839e4147522094ca40e41fe6e48e7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 21:05:03 -0800 Subject: Use wait_until_guild_available instead of wait_until_ready It has a much better guarantee that the cache will be available. --- bot/cogs/antispam.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/doc.py | 2 +- bot/cogs/duck_pond.py | 2 +- bot/cogs/logging.py | 2 +- bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/off_topic_names.py | 2 +- bot/cogs/reddit.py | 4 ++-- bot/cogs/reminders.py | 2 +- bot/cogs/sync/cog.py | 2 +- bot/cogs/verification.py | 2 +- bot/cogs/watchchannels/watchchannel.py | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index f67ef6f05..baa6b9459 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -123,7 +123,7 @@ class AntiSpam(Cog): async def alert_on_validation_error(self) -> None: """Unloads the cog and alerts admins if configuration validation failed.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() if self.validation_errors: body = "**The following errors were encountered:**\n" body += "\n".join(f"- {error}" for error in self.validation_errors.values()) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 3e7350fcc..b97e2356f 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -59,7 +59,7 @@ class Defcon(Cog): async def sync_settings(self) -> None: """On cog load, try to synchronize DEFCON settings to the API.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() self.channel = await self.bot.fetch_channel(Channels.defcon) try: diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 6e7c00b6a..204cffb37 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -157,7 +157,7 @@ class Doc(commands.Cog): async def init_refresh_inventory(self) -> None: """Refresh documentation inventory on cog initialization.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() await self.refresh_inventory() async def update_single( diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 345d2856c..1f84a0609 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -22,7 +22,7 @@ class DuckPond(Cog): async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() try: self.webhook = await self.bot.fetch_webhook(self.webhook_id) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index d1b7dcab3..dbd76672f 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -20,7 +20,7 @@ class Logging(Cog): async def startup_greeting(self) -> None: """Announce our presence to the configured devlog channel.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() log.info("Bot connected!") embed = Embed(description="Connected!") diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e14c302cb..a332fefa5 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -38,7 +38,7 @@ class InfractionScheduler(Scheduler): async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: """Schedule expiration for previous infractions.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index bf777ea5a..81511f99d 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -88,7 +88,7 @@ class OffTopicNames(Cog): async def init_offtopic_updater(self) -> None: """Start off-topic channel updating event loop if it hasn't already started.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() if self.updater_task is None: coro = update_names(self.bot) self.updater_task = self.bot.loop.create_task(coro) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index aa487f18e..4f6584aba 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -48,7 +48,7 @@ class Reddit(Cog): async def init_reddit_ready(self) -> None: """Sets the reddit webhook when the cog is loaded.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() if not self.webhook: self.webhook = await self.bot.fetch_webhook(Webhooks.reddit) @@ -208,7 +208,7 @@ class Reddit(Cog): await asyncio.sleep(seconds_until) - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() if not self.webhook: await self.bot.fetch_webhook(Webhooks.reddit) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 45bf9a8f4..89066e5d4 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -35,7 +35,7 @@ class Reminders(Scheduler, Cog): async def reschedule_reminders(self) -> None: """Get all current reminders from the API and reschedule them.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() response = await self.bot.api_client.get( 'bot/reminders', params={'active': 'true'} diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 4e6ed156b..9ef3b0c54 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -34,7 +34,7 @@ class Sync(Cog): async def sync_guild(self) -> None: """Syncs the roles/users of the guild with the database.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: for syncer in self.ON_READY_SYNCERS: diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 988e0d49a..07838c7bd 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -223,7 +223,7 @@ class Verification(Cog): @periodic_ping.before_loop async def before_ping(self) -> None: """Only start the loop when the bot is ready.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() def cog_unload(self) -> None: """Cancel the periodic ping task when the cog is unloaded.""" diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index eb787b083..3667a80e8 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -91,7 +91,7 @@ class WatchChannel(metaclass=CogABCMeta): async def start_watchchannel(self) -> None: """Starts the watch channel by getting the channel, webhook, and user cache ready.""" - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() try: self.channel = await self.bot.fetch_channel(self.destination) -- cgit v1.2.3 From e980dab7aa7e2fb6a402b452a376bf94f899989d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 20 Dec 2019 21:26:46 -0800 Subject: Constants: add dev-core channel and check mark emoji --- bot/constants.py | 2 ++ config-default.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index fe8e57322..6279388de 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -263,6 +263,7 @@ class Emojis(metaclass=YAMLGetter): new: str pencil: str cross_mark: str + check_mark: str ducky_yellow: int ducky_blurple: int @@ -365,6 +366,7 @@ class Channels(metaclass=YAMLGetter): bot: int checkpoint_test: int defcon: int + devcore: int devlog: int devtest: int esoteric: int diff --git a/config-default.yml b/config-default.yml index fda14b511..74dcc1862 100644 --- a/config-default.yml +++ b/config-default.yml @@ -34,6 +34,7 @@ style: pencil: "\u270F" new: "\U0001F195" cross_mark: "\u274C" + check_mark: "\u2705" ducky_yellow: &DUCKY_YELLOW 574951975574175744 ducky_blurple: &DUCKY_BLURPLE 574951975310065675 @@ -121,6 +122,7 @@ guild: bot: 267659945086812160 checkpoint_test: 422077681434099723 defcon: &DEFCON 464469101889454091 + devcore: 411200599653351425 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 esoteric: 470884583684964352 -- cgit v1.2.3 From 705963a2bf477b8536846683f9f2598ee788d3dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 11:16:07 -0800 Subject: API: define functions with keyword-only arguments This seems to have been the intent of the original implementation. --- bot/api.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/api.py b/bot/api.py index 56db99828..992499809 100644 --- a/bot/api.py +++ b/bot/api.py @@ -85,43 +85,43 @@ class APIClient: response_text = await response.text() raise ResponseCodeError(response=response, response_text=response_text) - async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API GET.""" await self._ready.wait() - async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.get(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PATCH.""" await self._ready.wait() - async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.patch(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API POST.""" await self._ready.wait() - async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.post(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PUT.""" await self._ready.wait() - async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.put(self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]: + async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: """Site API DELETE.""" await self._ready.wait() - async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: + async with self.session.delete(self._url_for(endpoint), **kwargs) as resp: if resp.status == 204: return None -- cgit v1.2.3 From d3bc9a978e2ff348ff33dfef26f430d59b89695f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 11:27:00 -0800 Subject: API: create request function which has a param for the HTTP method Reduces code redundancy. --- bot/api.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/bot/api.py b/bot/api.py index 992499809..a9d2baa4d 100644 --- a/bot/api.py +++ b/bot/api.py @@ -85,37 +85,29 @@ class APIClient: response_text = await response.text() raise ResponseCodeError(response=response, response_text=response_text) - async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Site API GET.""" + async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Send an HTTP request to the site API and return the JSON response.""" await self._ready.wait() - async with self.session.get(self._url_for(endpoint), **kwargs) as resp: + async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp: await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() + async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: + """Site API GET.""" + return await self.request("GET", endpoint, raise_for_status=raise_for_status, **kwargs) + async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PATCH.""" - await self._ready.wait() - - async with self.session.patch(self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() + return await self.request("PATCH", endpoint, raise_for_status=raise_for_status, **kwargs) async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API POST.""" - await self._ready.wait() - - async with self.session.post(self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() + return await self.request("POST", endpoint, raise_for_status=raise_for_status, **kwargs) async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: """Site API PUT.""" - await self._ready.wait() - - async with self.session.put(self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() + return await self.request("PUT", endpoint, raise_for_status=raise_for_status, **kwargs) async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: """Site API DELETE.""" -- cgit v1.2.3 From cc8b58bf7a1937a78e2f4edf9a67ad4460bb84dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 09:55:47 -0800 Subject: Sync: refactor cog * Use ID from constants directly instead of SYNC_SERVER_ID * Use f-strings instead of %s for logging * Fit into margin of 100 * Invert condition to reduce nesting * Use Any instead of incorrect function annotation for JSON values --- bot/cogs/sync/cog.py | 51 ++++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 9ef3b0c54..eff942cdb 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,5 +1,5 @@ import logging -from typing import Callable, Dict, Iterable, Union +from typing import Any, Callable, Dict, Iterable from discord import Guild, Member, Role, User from discord.ext import commands @@ -16,11 +16,6 @@ log = logging.getLogger(__name__) class Sync(Cog): """Captures relevant events and sends them to the site.""" - # The server to synchronize events on. - # Note that setting this wrongly will result in things getting deleted - # that possibly shouldn't be. - SYNC_SERVER_ID = constants.Guild.id - # An iterable of callables that are called when the bot is ready. ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( syncers.sync_roles, @@ -35,26 +30,31 @@ class Sync(Cog): async def sync_guild(self) -> None: """Syncs the roles/users of the guild with the database.""" await self.bot.wait_until_guild_available() - guild = self.bot.get_guild(self.SYNC_SERVER_ID) - if guild is not None: - for syncer in self.ON_READY_SYNCERS: - syncer_name = syncer.__name__[5:] # drop off `sync_` - log.info("Starting `%s` syncer.", syncer_name) - total_created, total_updated, total_deleted = await syncer(self.bot, guild) - if total_deleted is None: - log.info( - f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`." - ) - else: - log.info( - f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`, " - f"deleted `{total_deleted}`." - ) - - async def patch_user(self, user_id: int, updated_information: Dict[str, Union[str, int]]) -> None: + + guild = self.bot.get_guild(constants.Guild.id) + if guild is None: + return + + for syncer in self.ON_READY_SYNCERS: + syncer_name = syncer.__name__[5:] # drop off `sync_` + log.info(f"Starting {syncer_name} syncer.") + total_created, total_updated, total_deleted = await syncer(self.bot, guild) + + if total_deleted is None: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`." + ) + else: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`, deleted `{total_deleted}`." + ) + + async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" try: - await self.bot.api_client.patch("bot/users/" + str(user_id), json=updated_information) + await self.bot.api_client.patch(f"bot/users/{user_id}", json=updated_information) except ResponseCodeError as e: if e.response.status != 404: raise @@ -160,7 +160,8 @@ class Sync(Cog): @Cog.listener() async def on_user_update(self, before: User, after: User) -> None: """Update the user information in the database if a relevant change is detected.""" - if any(getattr(before, attr) != getattr(after, attr) for attr in ("name", "discriminator", "avatar")): + attrs = ("name", "discriminator", "avatar") + if any(getattr(before, attr) != getattr(after, attr) for attr in attrs): updated_information = { "name": after.name, "discriminator": int(after.discriminator), -- cgit v1.2.3 From cad6882b2a777041ba0eef3ac4a19b51ac092b60 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 14:05:36 -0800 Subject: Sync: create function for running a single syncer --- bot/cogs/sync/cog.py | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index eff942cdb..cefecd163 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Callable, Dict, Iterable +from typing import Any, Callable, Coroutine, Dict, Optional, Tuple from discord import Guild, Member, Role, User from discord.ext import commands @@ -12,16 +12,13 @@ from bot.cogs.sync import syncers log = logging.getLogger(__name__) +SyncerResult = Tuple[Optional[int], Optional[int], Optional[int]] +Syncer = Callable[[Bot, Guild], Coroutine[Any, Any, SyncerResult]] + class Sync(Cog): """Captures relevant events and sends them to the site.""" - # An iterable of callables that are called when the bot is ready. - ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( - syncers.sync_roles, - syncers.sync_users - ) - def __init__(self, bot: Bot) -> None: self.bot = bot @@ -35,21 +32,26 @@ class Sync(Cog): if guild is None: return - for syncer in self.ON_READY_SYNCERS: - syncer_name = syncer.__name__[5:] # drop off `sync_` - log.info(f"Starting {syncer_name} syncer.") - total_created, total_updated, total_deleted = await syncer(self.bot, guild) - - if total_deleted is None: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`." - ) - else: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`, deleted `{total_deleted}`." - ) + for syncer_name in (syncers.sync_roles, syncers.sync_users): + await self.sync(syncer_name, guild) + + async def sync(self, syncer: Syncer, guild: Guild) -> None: + """Run the named syncer for the given guild.""" + syncer_name = syncer.__name__[5:] # drop off `sync_` + + log.info(f"Starting {syncer_name} syncer.") + total_created, total_updated, total_deleted = await syncer(self.bot, guild) + + if total_deleted is None: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`." + ) + else: + log.info( + f"`{syncer_name}` syncer finished: created `{total_created}`, " + f"updated `{total_updated}`, deleted `{total_deleted}`." + ) async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" -- cgit v1.2.3 From 0d8890b6762e0066fccf4e8a0b8f9759f5b1d4a8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 14:15:12 -0800 Subject: Sync: support multiple None totals returns from a syncer --- bot/cogs/sync/cog.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index cefecd163..ccfbd201d 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -40,18 +40,15 @@ class Sync(Cog): syncer_name = syncer.__name__[5:] # drop off `sync_` log.info(f"Starting {syncer_name} syncer.") - total_created, total_updated, total_deleted = await syncer(self.bot, guild) - if total_deleted is None: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`." - ) + totals = await syncer(self.bot, guild) + totals = zip(("created", "updated", "deleted"), totals) + results = ", ".join(f"{name} `{total}`" for name, total in totals if total is not None) + + if results: + log.info(f"`{syncer_name}` syncer finished: {results}.") else: - log.info( - f"`{syncer_name}` syncer finished: created `{total_created}`, " - f"updated `{total_updated}`, deleted `{total_deleted}`." - ) + log.warning(f"`{syncer_name}` syncer aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" -- cgit v1.2.3 From 471efe41fa226e5890d715c24549c808603274e9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 21 Dec 2019 14:21:35 -0800 Subject: Sync: support sending messages to a context in sync() --- bot/cogs/sync/cog.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ccfbd201d..a80906cae 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -35,11 +35,13 @@ class Sync(Cog): for syncer_name in (syncers.sync_roles, syncers.sync_users): await self.sync(syncer_name, guild) - async def sync(self, syncer: Syncer, guild: Guild) -> None: + async def sync(self, syncer: Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: """Run the named syncer for the given guild.""" syncer_name = syncer.__name__[5:] # drop off `sync_` log.info(f"Starting {syncer_name} syncer.") + if ctx: + message = await ctx.send(f"📊 Synchronizing {syncer_name}.") totals = await syncer(self.bot, guild) totals = zip(("created", "updated", "deleted"), totals) @@ -47,8 +49,14 @@ class Sync(Cog): if results: log.info(f"`{syncer_name}` syncer finished: {results}.") + if ctx: + await message.edit( + content=f":ok_hand: Synchronization of {syncer_name} complete: {results}" + ) else: log.warning(f"`{syncer_name}` syncer aborted!") + if ctx: + await message.edit(content=f":x: Synchronization of {syncer_name} aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -177,24 +185,10 @@ class Sync(Cog): @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronize the guild's roles with the roles on the site.""" - initial_response = await ctx.send("📊 Synchronizing roles.") - total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild) - await initial_response.edit( - content=( - f"👌 Role synchronization complete, created **{total_created}** " - f", updated **{total_created}** roles, and deleted **{total_deleted}** roles." - ) - ) + await self.sync(syncers.sync_roles, ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronize the guild's users with the users on the site.""" - initial_response = await ctx.send("📊 Synchronizing users.") - total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild) - await initial_response.edit( - content=( - f"👌 User synchronization complete, created **{total_created}** " - f"and updated **{total_created}** users." - ) - ) + await self.sync(syncers.sync_users, ctx.guild, ctx) -- cgit v1.2.3 From 9120159ce61e9a0d50f077627701404daa6c416e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 24 Dec 2019 21:08:22 -0800 Subject: Sync: create classes for syncers Replaces the functions with a class for each syncer. The classes inherit from a Syncer base class. A NamedTuple was also created to replace the tuple of the object differences that was previously being returned. * Use namedtuple._asdict to simplify converting namedtuples to JSON --- bot/cogs/sync/cog.py | 38 ++--- bot/cogs/sync/syncers.py | 362 ++++++++++++++++++----------------------------- 2 files changed, 158 insertions(+), 242 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index a80906cae..1670278e0 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Callable, Coroutine, Dict, Optional, Tuple +from typing import Any, Dict, Optional from discord import Guild, Member, Role, User from discord.ext import commands @@ -12,15 +12,14 @@ from bot.cogs.sync import syncers log = logging.getLogger(__name__) -SyncerResult = Tuple[Optional[int], Optional[int], Optional[int]] -Syncer = Callable[[Bot, Guild], Coroutine[Any, Any, SyncerResult]] - class Sync(Cog): """Captures relevant events and sends them to the site.""" def __init__(self, bot: Bot) -> None: self.bot = bot + self.role_syncer = syncers.RoleSyncer(self.bot.api_client) + self.user_syncer = syncers.UserSyncer(self.bot.api_client) self.bot.loop.create_task(self.sync_guild()) @@ -32,31 +31,34 @@ class Sync(Cog): if guild is None: return - for syncer_name in (syncers.sync_roles, syncers.sync_users): - await self.sync(syncer_name, guild) + for syncer in (self.role_syncer, self.user_syncer): + await self.sync(syncer, guild) - async def sync(self, syncer: Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: + @staticmethod + async def sync(syncer: syncers.Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: """Run the named syncer for the given guild.""" - syncer_name = syncer.__name__[5:] # drop off `sync_` + syncer_name = syncer.__class__.__name__[-6:].lower() # Drop off "Syncer" suffix log.info(f"Starting {syncer_name} syncer.") if ctx: - message = await ctx.send(f"📊 Synchronizing {syncer_name}.") + message = await ctx.send(f"📊 Synchronizing {syncer_name}s.") + + diff = await syncer.get_diff(guild) + await syncer.sync(diff) - totals = await syncer(self.bot, guild) - totals = zip(("created", "updated", "deleted"), totals) - results = ", ".join(f"{name} `{total}`" for name, total in totals if total is not None) + totals = zip(("created", "updated", "deleted"), diff) + results = ", ".join(f"{name} `{len(total)}`" for name, total in totals if total is not None) if results: - log.info(f"`{syncer_name}` syncer finished: {results}.") + log.info(f"{syncer_name} syncer finished: {results}.") if ctx: await message.edit( - content=f":ok_hand: Synchronization of {syncer_name} complete: {results}" + content=f":ok_hand: Synchronization of {syncer_name}s complete: {results}" ) else: - log.warning(f"`{syncer_name}` syncer aborted!") + log.warning(f"{syncer_name} syncer aborted!") if ctx: - await message.edit(content=f":x: Synchronization of {syncer_name} aborted!") + await message.edit(content=f":x: Synchronization of {syncer_name}s aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -185,10 +187,10 @@ class Sync(Cog): @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronize the guild's roles with the roles on the site.""" - await self.sync(syncers.sync_roles, ctx.guild, ctx) + await self.sync(self.role_syncer, ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronize the guild's users with the users on the site.""" - await self.sync(syncers.sync_users, ctx.guild, ctx) + await self.sync(self.user_syncer, ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 14cf51383..356831922 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,9 +1,12 @@ +import abc +import typing as t from collections import namedtuple -from typing import Dict, Set, Tuple from discord import Guild -from bot.bot import Bot +from bot.api import APIClient + +_T = t.TypeVar("_T") # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. @@ -11,225 +14,136 @@ Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) -def get_roles_for_sync( - guild_roles: Set[Role], api_roles: Set[Role] -) -> Tuple[Set[Role], Set[Role], Set[Role]]: - """ - Determine which roles should be created or updated on the site. - - Arguments: - guild_roles (Set[Role]): - Roles that were found on the guild at startup. - - api_roles (Set[Role]): - Roles that were retrieved from the API at startup. - - Returns: - Tuple[Set[Role], Set[Role]. Set[Role]]: - A tuple with three elements. The first element represents - roles to be created on the site, meaning that they were - present on the cached guild but not on the API. The second - element represents roles to be updated, meaning they were - present on both the cached guild and the API but non-ID - fields have changed inbetween. The third represents roles - to be deleted on the site, meaning the roles are present on - the API but not in the cached guild. - """ - guild_role_ids = {role.id for role in guild_roles} - api_role_ids = {role.id for role in api_roles} - new_role_ids = guild_role_ids - api_role_ids - deleted_role_ids = api_role_ids - guild_role_ids - - # New roles are those which are on the cached guild but not on the - # API guild, going by the role ID. We need to send them in for creation. - roles_to_create = {role for role in guild_roles if role.id in new_role_ids} - roles_to_update = guild_roles - api_roles - roles_to_create - roles_to_delete = {role for role in api_roles if role.id in deleted_role_ids} - return roles_to_create, roles_to_update, roles_to_delete - - -async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]: - """ - Synchronize roles found on the given `guild` with the ones on the API. - - Arguments: - bot (bot.bot.Bot): - The bot instance that we're running with. - - guild (discord.Guild): - The guild instance from the bot's cache - to synchronize roles with. - - Returns: - Tuple[int, int, int]: - A tuple with three integers representing how many roles were created - (element `0`) , how many roles were updated (element `1`), and how many - roles were deleted (element `2`) on the API. - """ - roles = await bot.api_client.get('bot/roles') - - # Pack API roles and guild roles into one common format, - # which is also hashable. We need hashability to be able - # to compare these easily later using sets. - api_roles = {Role(**role_dict) for role_dict in roles} - guild_roles = { - Role( - id=role.id, name=role.name, - colour=role.colour.value, permissions=role.permissions.value, - position=role.position, - ) - for role in guild.roles - } - roles_to_create, roles_to_update, roles_to_delete = get_roles_for_sync(guild_roles, api_roles) - - for role in roles_to_create: - await bot.api_client.post( - 'bot/roles', - json={ - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions, - 'position': role.position, - } - ) - - for role in roles_to_update: - await bot.api_client.put( - f'bot/roles/{role.id}', - json={ - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions, - 'position': role.position, - } - ) - - for role in roles_to_delete: - await bot.api_client.delete(f'bot/roles/{role.id}') - - return len(roles_to_create), len(roles_to_update), len(roles_to_delete) - - -def get_users_for_sync( - guild_users: Dict[int, User], api_users: Dict[int, User] -) -> Tuple[Set[User], Set[User]]: - """ - Determine which users should be created or updated on the website. - - Arguments: - guild_users (Dict[int, User]): - A mapping of user IDs to user data, populated from the - guild cached on the running bot instance. - - api_users (Dict[int, User]): - A mapping of user IDs to user data, populated from the API's - current inventory of all users. - - Returns: - Tuple[Set[User], Set[User]]: - Two user sets as a tuple. The first element represents users - to be created on the website, these are users that are present - in the cached guild data but not in the API at all, going by - their ID. The second element represents users to update. It is - populated by users which are present on both the API and the - guild, but where the attribute of a user on the API is not - equal to the attribute of the user on the guild. - """ - users_to_create = set() - users_to_update = set() - - for api_user in api_users.values(): - guild_user = guild_users.get(api_user.id) - if guild_user is not None: - if api_user != guild_user: - users_to_update.add(guild_user) - - elif api_user.in_guild: - # The user is known on the API but not the guild, and the - # API currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - new_api_user = api_user._replace(in_guild=False) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(api_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) - - return users_to_create, users_to_update - - -async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: - """ - Synchronize users found in the given `guild` with the ones in the API. - - Arguments: - bot (bot.bot.Bot): - The bot instance that we're running with. - - guild (discord.Guild): - The guild instance from the bot's cache - to synchronize roles with. - - Returns: - Tuple[int, int, None]: - A tuple with two integers, representing how many users were created - (element `0`) and how many users were updated (element `1`), and `None` - to indicate that a user sync never deletes entries from the API. - """ - current_users = await bot.api_client.get('bot/users') - - # Pack API users and guild users into one common format, - # which is also hashable. We need hashability to be able - # to compare these easily later using sets. - api_users = { - user_dict['id']: User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in current_users - } - guild_users = { - member.id: User( - id=member.id, name=member.name, - discriminator=int(member.discriminator), avatar_hash=member.avatar, - roles=tuple(sorted(role.id for role in member.roles)), in_guild=True - ) - for member in guild.members - } - - users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) - - for user in users_to_create: - await bot.api_client.post( - 'bot/users', - json={ - 'avatar_hash': user.avatar_hash, - 'discriminator': user.discriminator, - 'id': user.id, - 'in_guild': user.in_guild, - 'name': user.name, - 'roles': list(user.roles) - } - ) - - for user in users_to_update: - await bot.api_client.put( - f'bot/users/{user.id}', - json={ - 'avatar_hash': user.avatar_hash, - 'discriminator': user.discriminator, - 'id': user.id, - 'in_guild': user.in_guild, - 'name': user.name, - 'roles': list(user.roles) - } - ) - - return len(users_to_create), len(users_to_update), None +class Diff(t.NamedTuple, t.Generic[_T]): + """The differences between the Discord cache and the contents of the database.""" + + created: t.Optional[t.Set[_T]] = None + updated: t.Optional[t.Set[_T]] = None + deleted: t.Optional[t.Set[_T]] = None + + +class Syncer(abc.ABC, t.Generic[_T]): + """Base class for synchronising the database with objects in the Discord cache.""" + + def __init__(self, api_client: APIClient) -> None: + self.api_client = api_client + + @abc.abstractmethod + async def get_diff(self, guild: Guild) -> Diff[_T]: + """Return objects of `guild` with which to synchronise the database.""" + raise NotImplementedError + + @abc.abstractmethod + async def sync(self, diff: Diff[_T]) -> None: + """Synchronise the database with the given `diff`.""" + raise NotImplementedError + + +class RoleSyncer(Syncer[Role]): + """Synchronise the database with roles in the cache.""" + + async def get_diff(self, guild: Guild) -> Diff[Role]: + """Return the roles of `guild` with which to synchronise the database.""" + roles = await self.api_client.get('bot/roles') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_roles = {Role(**role_dict) for role_dict in roles} + guild_roles = { + Role( + id=role.id, + name=role.name, + colour=role.colour.value, + permissions=role.permissions.value, + position=role.position, + ) + for role in guild.roles + } + + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in db_roles} + new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids + + # New roles are those which are on the cached guild but not on the + # DB guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - db_roles - roles_to_create + roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} + + return Diff(roles_to_create, roles_to_update, roles_to_delete) + + async def sync(self, diff: Diff[Role]) -> None: + """Synchronise roles in the database with the given `diff`.""" + for role in diff.created: + await self.api_client.post('bot/roles', json={**role._asdict()}) + + for role in diff.updated: + await self.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) + + for role in diff.deleted: + await self.api_client.delete(f'bot/roles/{role.id}') + + +class UserSyncer(Syncer[User]): + """Synchronise the database with users in the cache.""" + + async def get_diff(self, guild: Guild) -> Diff[User]: + """Return the users of `guild` with which to synchronise the database.""" + users = await self.api_client.get('bot/users') + + # Pack DB roles and guild roles into one common, hashable format. + # They're hashable so that they're easily comparable with sets later. + db_users = { + user_dict['id']: User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in users + } + guild_users = { + member.id: User( + id=member.id, + name=member.name, + discriminator=int(member.discriminator), + avatar_hash=member.avatar, + roles=tuple(sorted(role.id for role in member.roles)), + in_guild=True + ) + for member in guild.members + } + + users_to_create = set() + users_to_update = set() + + for db_user in db_users.values(): + guild_user = guild_users.get(db_user.id) + if guild_user is not None: + if db_user != guild_user: + users_to_update.add(guild_user) + + elif db_user.in_guild: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = db_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(db_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return Diff(users_to_create, users_to_update) + + async def sync(self, diff: Diff[User]) -> None: + """Synchronise users in the database with the given `diff`.""" + for user in diff.created: + await self.api_client.post('bot/users', json={**user._asdict()}) + + for user in diff.updated: + await self.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) -- cgit v1.2.3 From 9e8fe747c155226756e01ab2961a7ae3cfdb6f19 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 24 Dec 2019 22:23:12 -0800 Subject: Sync: prompt to confirm when diff is greater than 10 The confirmation prompt will be sent to the dev-core channel or the specified context. Confirmation is done via reactions and waits 5 minutes before timing out. * Add name property to Syncers * Make _get_diff private; only sync() needs to be called now * Change spelling of synchronize to synchronise * Update docstrings --- bot/cogs/sync/cog.py | 25 ++++--- bot/cogs/sync/syncers.py | 170 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 158 insertions(+), 37 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 1670278e0..1fd39b544 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -36,29 +36,28 @@ class Sync(Cog): @staticmethod async def sync(syncer: syncers.Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: - """Run the named syncer for the given guild.""" - syncer_name = syncer.__class__.__name__[-6:].lower() # Drop off "Syncer" suffix - - log.info(f"Starting {syncer_name} syncer.") + """Run `syncer` using the cache of the given `guild`.""" + log.info(f"Starting {syncer.name} syncer.") if ctx: - message = await ctx.send(f"📊 Synchronizing {syncer_name}s.") + message = await ctx.send(f"📊 Synchronising {syncer.name}s.") - diff = await syncer.get_diff(guild) - await syncer.sync(diff) + diff = await syncer.sync(guild, ctx) + if not diff: + return # Sync was aborted. totals = zip(("created", "updated", "deleted"), diff) results = ", ".join(f"{name} `{len(total)}`" for name, total in totals if total is not None) if results: - log.info(f"{syncer_name} syncer finished: {results}.") + log.info(f"{syncer.name} syncer finished: {results}.") if ctx: await message.edit( - content=f":ok_hand: Synchronization of {syncer_name}s complete: {results}" + content=f":ok_hand: Synchronisation of {syncer.name}s complete: {results}" ) else: - log.warning(f"{syncer_name} syncer aborted!") + log.warning(f"{syncer.name} syncer aborted!") if ctx: - await message.edit(content=f":x: Synchronization of {syncer_name}s aborted!") + await message.edit(content=f":x: Synchronisation of {syncer.name}s aborted!") async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -186,11 +185,11 @@ class Sync(Cog): @sync_group.command(name='roles') @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: - """Manually synchronize the guild's roles with the roles on the site.""" + """Manually synchronise the guild's roles with the roles on the site.""" await self.sync(self.role_syncer, ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: - """Manually synchronize the guild's users with the users on the site.""" + """Manually synchronise the guild's users with the users on the site.""" await self.sync(self.user_syncer, ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 356831922..7608c6870 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,18 +1,23 @@ import abc +import logging import typing as t from collections import namedtuple -from discord import Guild +from discord import Guild, HTTPException +from discord.ext.commands import Context -from bot.api import APIClient +from bot import constants +from bot.bot import Bot -_T = t.TypeVar("_T") +log = logging.getLogger(__name__) # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) +_T = t.TypeVar("_T") + class Diff(t.NamedTuple, t.Generic[_T]): """The differences between the Discord cache and the contents of the database.""" @@ -25,26 +30,113 @@ class Diff(t.NamedTuple, t.Generic[_T]): class Syncer(abc.ABC, t.Generic[_T]): """Base class for synchronising the database with objects in the Discord cache.""" - def __init__(self, api_client: APIClient) -> None: - self.api_client = api_client + CONFIRM_TIMEOUT = 60 * 5 # 5 minutes + MAX_DIFF = 10 + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @property @abc.abstractmethod - async def get_diff(self, guild: Guild) -> Diff[_T]: - """Return objects of `guild` with which to synchronise the database.""" + def name(self) -> str: + """The name of the syncer; used in output messages and logging.""" raise NotImplementedError + async def _confirm(self, ctx: t.Optional[Context] = None) -> bool: + """ + Send a prompt to confirm or abort a sync using reactions and return True if confirmed. + + If no context is given, the prompt is sent to the dev-core channel and mentions the core + developers role. + """ + allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + + # Send to core developers if it's an automatic sync. + if not ctx: + mention = f'<@&{constants.Roles.core_developer}>' + channel = self.bot.get_channel(constants.Channels.devcore) + + if not channel: + try: + channel = self.bot.fetch_channel(constants.Channels.devcore) + except HTTPException: + log.exception( + f"Failed to fetch channel for sending sync confirmation prompt; " + f"aborting {self.name} sync." + ) + return False + else: + mention = ctx.author.mention + channel = ctx.channel + + message = await channel.send( + f'{mention} Possible cache issue while syncing {self.name}s. ' + f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) + + # Add the initial reactions. + for emoji in allowed_emoji: + await message.add_reaction(emoji) + + def check(_reaction, user): # noqa: TYP + return ( + _reaction.message.id == message.id + and True if not ctx else user == ctx.author # Skip author check for auto syncs + and str(_reaction.emoji) in allowed_emoji + ) + + reaction = None + try: + reaction, _ = await self.bot.wait_for( + 'reaction_add', + check=check, + timeout=self.CONFIRM_TIMEOUT + ) + except TimeoutError: + # reaction will remain none thus sync will be aborted in the finally block below. + pass + finally: + if str(reaction) == constants.Emojis.check_mark: + await channel.send(f':ok_hand: {self.name} sync will proceed.') + return True + else: + await channel.send(f':x: {self.name} sync aborted!') + return False + @abc.abstractmethod - async def sync(self, diff: Diff[_T]) -> None: - """Synchronise the database with the given `diff`.""" + async def _get_diff(self, guild: Guild) -> Diff[_T]: + """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError + @abc.abstractmethod + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: + """ + Synchronise the database with the cache of `guild` and return the synced difference. + + 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 sync is not confirmed, None is returned. + """ + diff = await self._get_diff(guild) + confirmed = await self._confirm(ctx) + + if not confirmed: + return None + else: + return diff + class RoleSyncer(Syncer[Role]): """Synchronise the database with roles in the cache.""" - async def get_diff(self, guild: Guild) -> Diff[Role]: - """Return the roles of `guild` with which to synchronise the database.""" - roles = await self.api_client.get('bot/roles') + name = "role" + + async def _get_diff(self, guild: Guild) -> Diff[Role]: + """Return the difference of roles between the cache of `guild` and the database.""" + roles = await self.bot.api_client.get('bot/roles') # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. @@ -73,24 +165,40 @@ class RoleSyncer(Syncer[Role]): return Diff(roles_to_create, roles_to_update, roles_to_delete) - async def sync(self, diff: Diff[Role]) -> None: - """Synchronise roles in the database with the given `diff`.""" + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[Role]]: + """ + Synchronise the database with the role cache of `guild` and return the synced difference. + + 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 sync is not confirmed, None is returned. + """ + diff = await super().sync(guild, ctx) + if diff is None: + return None + for role in diff.created: - await self.api_client.post('bot/roles', json={**role._asdict()}) + await self.bot.api_client.post('bot/roles', json={**role._asdict()}) for role in diff.updated: - await self.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) + await self.bot.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) for role in diff.deleted: - await self.api_client.delete(f'bot/roles/{role.id}') + await self.bot.api_client.delete(f'bot/roles/{role.id}') + + return diff class UserSyncer(Syncer[User]): """Synchronise the database with users in the cache.""" - async def get_diff(self, guild: Guild) -> Diff[User]: - """Return the users of `guild` with which to synchronise the database.""" - users = await self.api_client.get('bot/users') + name = "user" + + async def _get_diff(self, guild: Guild) -> Diff[User]: + """Return the difference of users between the cache of `guild` and the database.""" + users = await self.bot.api_client.get('bot/users') # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. @@ -140,10 +248,24 @@ class UserSyncer(Syncer[User]): return Diff(users_to_create, users_to_update) - async def sync(self, diff: Diff[User]) -> None: - """Synchronise users in the database with the given `diff`.""" + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: + """ + Synchronise the database with the user cache of `guild` and return the synced difference. + + 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 sync is not confirmed, None is returned. + """ + diff = await super().sync(guild, ctx) + if diff is None: + return None + for user in diff.created: - await self.api_client.post('bot/users', json={**user._asdict()}) + await self.bot.api_client.post('bot/users', json={**user._asdict()}) for user in diff.updated: - await self.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) + await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) + + return diff -- cgit v1.2.3 From d059452b94ec8b54bace70852afe1c3b77ce64ff Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 09:40:04 -0800 Subject: Sync: move sync logic into Syncer base class The interface was becoming cumbersome to work with so it was all moved to a single location. Now just calling Syncer.sync() will take care of everything. * Remove Optional type annotation from Diff attributes * _confirm() can edit the original message and use it as the prompt * Calculate the total diff and compare it against the max before sending a confirmation prompt * Remove abort message from sync(); _confirm() will handle that --- bot/cogs/sync/cog.py | 39 +++-------------- bot/cogs/sync/syncers.py | 108 +++++++++++++++++++++-------------------------- 2 files changed, 54 insertions(+), 93 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 1fd39b544..66ffbabf9 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,7 +1,7 @@ import logging -from typing import Any, Dict, Optional +from typing import Any, Dict -from discord import Guild, Member, Role, User +from discord import Member, Role, User from discord.ext import commands from discord.ext.commands import Cog, Context @@ -18,8 +18,8 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.role_syncer = syncers.RoleSyncer(self.bot.api_client) - self.user_syncer = syncers.UserSyncer(self.bot.api_client) + self.role_syncer = syncers.RoleSyncer(self.bot) + self.user_syncer = syncers.UserSyncer(self.bot) self.bot.loop.create_task(self.sync_guild()) @@ -32,32 +32,7 @@ class Sync(Cog): return for syncer in (self.role_syncer, self.user_syncer): - await self.sync(syncer, guild) - - @staticmethod - async def sync(syncer: syncers.Syncer, guild: Guild, ctx: Optional[Context] = None) -> None: - """Run `syncer` using the cache of the given `guild`.""" - log.info(f"Starting {syncer.name} syncer.") - if ctx: - message = await ctx.send(f"📊 Synchronising {syncer.name}s.") - - diff = await syncer.sync(guild, ctx) - if not diff: - return # Sync was aborted. - - totals = zip(("created", "updated", "deleted"), diff) - results = ", ".join(f"{name} `{len(total)}`" for name, total in totals if total is not None) - - if results: - log.info(f"{syncer.name} syncer finished: {results}.") - if ctx: - await message.edit( - content=f":ok_hand: Synchronisation of {syncer.name}s complete: {results}" - ) - else: - log.warning(f"{syncer.name} syncer aborted!") - if ctx: - await message.edit(content=f":x: Synchronisation of {syncer.name}s aborted!") + await syncer.sync(guild) async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: """Send a PATCH request to partially update a user in the database.""" @@ -186,10 +161,10 @@ class Sync(Cog): @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronise the guild's roles with the roles on the site.""" - await self.sync(self.role_syncer, ctx.guild, ctx) + await self.role_syncer.sync(ctx.guild, ctx) @sync_group.command(name='users') @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronise the guild's users with the users on the site.""" - await self.sync(self.user_syncer, ctx.guild, ctx) + await self.user_syncer.sync(ctx.guild, ctx) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 7608c6870..7cc518348 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -3,7 +3,7 @@ import logging import typing as t from collections import namedtuple -from discord import Guild, HTTPException +from discord import Guild, HTTPException, Message from discord.ext.commands import Context from bot import constants @@ -22,9 +22,9 @@ _T = t.TypeVar("_T") class Diff(t.NamedTuple, t.Generic[_T]): """The differences between the Discord cache and the contents of the database.""" - created: t.Optional[t.Set[_T]] = None - updated: t.Optional[t.Set[_T]] = None - deleted: t.Optional[t.Set[_T]] = None + created: t.Set[_T] = {} + updated: t.Set[_T] = {} + deleted: t.Set[_T] = {} class Syncer(abc.ABC, t.Generic[_T]): @@ -42,18 +42,22 @@ class Syncer(abc.ABC, t.Generic[_T]): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError - async def _confirm(self, ctx: t.Optional[Context] = None) -> bool: + async def _confirm(self, message: t.Optional[Message] = None) -> bool: """ Send a prompt to confirm or abort a sync using reactions and return True if confirmed. - If no context is given, the prompt is sent to the dev-core channel and mentions the core - developers role. + If a message is given, it is edited to display the prompt and reactions. Otherwise, a new + message is sent to the dev-core channel and mentions the core developers role. """ allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + msg_content = ( + f'Possible cache issue while syncing {self.name}s. ' + f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' + f'React to confirm or abort the sync.' + ) # Send to core developers if it's an automatic sync. - if not ctx: - mention = f'<@&{constants.Roles.core_developer}>' + if not message: channel = self.bot.get_channel(constants.Channels.devcore) if not channel: @@ -65,24 +69,20 @@ class Syncer(abc.ABC, t.Generic[_T]): f"aborting {self.name} sync." ) return False - else: - mention = ctx.author.mention - channel = ctx.channel - message = await channel.send( - f'{mention} Possible cache issue while syncing {self.name}s. ' - f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) + message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") + else: + message = await message.edit(content=f"{message.author.mention} {msg_content}") # Add the initial reactions. for emoji in allowed_emoji: await message.add_reaction(emoji) def check(_reaction, user): # noqa: TYP + # Skip author check for auto syncs return ( _reaction.message.id == message.id - and True if not ctx else user == ctx.author # Skip author check for auto syncs + and True if message.author.bot else user == message.author and str(_reaction.emoji) in allowed_emoji ) @@ -98,10 +98,11 @@ class Syncer(abc.ABC, t.Generic[_T]): pass finally: if str(reaction) == constants.Emojis.check_mark: - await channel.send(f':ok_hand: {self.name} sync will proceed.') + await message.edit(content=f':ok_hand: {self.name} sync will proceed.') return True else: - await channel.send(f':x: {self.name} sync aborted!') + log.warning(f"{self.name} syncer aborted!") + await message.edit(content=f':x: {self.name} sync aborted!') return False @abc.abstractmethod @@ -110,23 +111,36 @@ class Syncer(abc.ABC, t.Generic[_T]): raise NotImplementedError @abc.abstractmethod - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: + async def _sync(self, diff: Diff[_T]) -> None: + """Perform the API calls for synchronisation.""" + raise NotImplementedError + + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: """ - Synchronise the database with the cache of `guild` and return the synced difference. + 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 sync is not confirmed, None is returned. """ + log.info(f"Starting {self.name} syncer.") + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + diff = await self._get_diff(guild) - confirmed = await self._confirm(ctx) + total = sum(map(len, diff)) - if not confirmed: - return None - else: - return diff + if total > self.MAX_DIFF and not await self._confirm(ctx): + return # Sync aborted. + + await self._sync(diff) + + results = ", ".join(f"{name} `{len(total)}`" for name, total in diff._asdict().items()) + log.info(f"{self.name} syncer finished: {results}.") + if ctx: + await message.edit( + content=f":ok_hand: Synchronisation of {self.name}s complete: {results}" + ) class RoleSyncer(Syncer[Role]): @@ -165,20 +179,8 @@ class RoleSyncer(Syncer[Role]): return Diff(roles_to_create, roles_to_update, roles_to_delete) - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[Role]]: - """ - Synchronise the database with the role cache of `guild` and return the synced difference. - - 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 sync is not confirmed, None is returned. - """ - diff = await super().sync(guild, ctx) - if diff is None: - return None - + async def _sync(self, diff: Diff[Role]) -> None: + """Synchronise the database with the role cache of `guild`.""" for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) @@ -188,8 +190,6 @@ class RoleSyncer(Syncer[Role]): for role in diff.deleted: await self.bot.api_client.delete(f'bot/roles/{role.id}') - return diff - class UserSyncer(Syncer[User]): """Synchronise the database with users in the cache.""" @@ -248,24 +248,10 @@ class UserSyncer(Syncer[User]): return Diff(users_to_create, users_to_update) - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> t.Optional[Diff[_T]]: - """ - Synchronise the database with the user cache of `guild` and return the synced difference. - - 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 sync is not confirmed, None is returned. - """ - diff = await super().sync(guild, ctx) - if diff is None: - return None - + async def _sync(self, diff: Diff[User]) -> None: + """Synchronise the database with the user cache of `guild`.""" for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) for user in diff.updated: await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) - - return diff -- cgit v1.2.3 From 617e54e0cd905c834d0153e019951d736c921d5c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:11:05 -0800 Subject: Sync: remove generic type from Diff It doesn't play along well with NamedTuple due to metaclass conflicts. The workaround involved created a NamedTuple-only base class, which does work but at the cost of confusing some static type checkers. Since Diff is now an internal data structure, it no longer really needs to have precise type annotations. Therefore, a normal namedtuple is adequate. --- bot/cogs/sync/syncers.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 7cc518348..394887bab 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -15,19 +15,10 @@ log = logging.getLogger(__name__) # something that we make use of when diffing site roles against guild roles. Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) +Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) -_T = t.TypeVar("_T") - -class Diff(t.NamedTuple, t.Generic[_T]): - """The differences between the Discord cache and the contents of the database.""" - - created: t.Set[_T] = {} - updated: t.Set[_T] = {} - deleted: t.Set[_T] = {} - - -class Syncer(abc.ABC, t.Generic[_T]): +class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" CONFIRM_TIMEOUT = 60 * 5 # 5 minutes @@ -106,12 +97,12 @@ class Syncer(abc.ABC, t.Generic[_T]): return False @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> Diff[_T]: + async def _get_diff(self, guild: Guild) -> Diff: """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError @abc.abstractmethod - async def _sync(self, diff: Diff[_T]) -> None: + async def _sync(self, diff: Diff) -> None: """Perform the API calls for synchronisation.""" raise NotImplementedError @@ -143,12 +134,12 @@ class Syncer(abc.ABC, t.Generic[_T]): ) -class RoleSyncer(Syncer[Role]): +class RoleSyncer(Syncer): """Synchronise the database with roles in the cache.""" name = "role" - async def _get_diff(self, guild: Guild) -> Diff[Role]: + async def _get_diff(self, guild: Guild) -> Diff: """Return the difference of roles between the cache of `guild` and the database.""" roles = await self.bot.api_client.get('bot/roles') @@ -179,7 +170,7 @@ class RoleSyncer(Syncer[Role]): return Diff(roles_to_create, roles_to_update, roles_to_delete) - async def _sync(self, diff: Diff[Role]) -> None: + async def _sync(self, diff: Diff) -> None: """Synchronise the database with the role cache of `guild`.""" for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) @@ -191,12 +182,12 @@ class RoleSyncer(Syncer[Role]): await self.bot.api_client.delete(f'bot/roles/{role.id}') -class UserSyncer(Syncer[User]): +class UserSyncer(Syncer): """Synchronise the database with users in the cache.""" name = "user" - async def _get_diff(self, guild: Guild) -> Diff[User]: + async def _get_diff(self, guild: Guild) -> Diff: """Return the difference of users between the cache of `guild` and the database.""" users = await self.bot.api_client.get('bot/users') @@ -248,7 +239,7 @@ class UserSyncer(Syncer[User]): return Diff(users_to_create, users_to_update) - async def _sync(self, diff: Diff[User]) -> None: + async def _sync(self, diff: Diff) -> None: """Synchronise the database with the user cache of `guild`.""" for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) -- cgit v1.2.3 From b9c06880f2f3c2f512a29932acbe3f4cf39f7f0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:12:07 -0800 Subject: Sync: make Role, User, and Diff private --- bot/cogs/sync/syncers.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 394887bab..0a0ce91d0 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -13,9 +13,9 @@ log = logging.getLogger(__name__) # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. -Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) -Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) +_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +_User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) +_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): @@ -97,12 +97,12 @@ class Syncer(abc.ABC): return False @abc.abstractmethod - async def _get_diff(self, guild: Guild) -> Diff: + async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" raise NotImplementedError @abc.abstractmethod - async def _sync(self, diff: Diff) -> None: + async def _sync(self, diff: _Diff) -> None: """Perform the API calls for synchronisation.""" raise NotImplementedError @@ -139,15 +139,15 @@ class RoleSyncer(Syncer): name = "role" - async def _get_diff(self, guild: Guild) -> Diff: + async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of roles between the cache of `guild` and the database.""" roles = await self.bot.api_client.get('bot/roles') # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. - db_roles = {Role(**role_dict) for role_dict in roles} + db_roles = {_Role(**role_dict) for role_dict in roles} guild_roles = { - Role( + _Role( id=role.id, name=role.name, colour=role.colour.value, @@ -168,9 +168,9 @@ class RoleSyncer(Syncer): roles_to_update = guild_roles - db_roles - roles_to_create roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids} - return Diff(roles_to_create, roles_to_update, roles_to_delete) + return _Diff(roles_to_create, roles_to_update, roles_to_delete) - async def _sync(self, diff: Diff) -> None: + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the role cache of `guild`.""" for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) @@ -187,21 +187,21 @@ class UserSyncer(Syncer): name = "user" - async def _get_diff(self, guild: Guild) -> Diff: + async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of users between the cache of `guild` and the database.""" users = await self.bot.api_client.get('bot/users') # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. db_users = { - user_dict['id']: User( + user_dict['id']: _User( roles=tuple(sorted(user_dict.pop('roles'))), **user_dict ) for user_dict in users } guild_users = { - member.id: User( + member.id: _User( id=member.id, name=member.name, discriminator=int(member.discriminator), @@ -237,9 +237,9 @@ class UserSyncer(Syncer): new_user = guild_users[user_id] users_to_create.add(new_user) - return Diff(users_to_create, users_to_update) + return _Diff(users_to_create, users_to_update) - async def _sync(self, diff: Diff) -> None: + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) -- cgit v1.2.3 From 4c9cb1f7a3e8134a11d37f130b115391b3c81b54 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:39:26 -0800 Subject: Sync: allow for None values in Diffs --- bot/cogs/sync/syncers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 0a0ce91d0..8b9fe1ad9 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -119,14 +119,14 @@ class Syncer(abc.ABC): message = await ctx.send(f"📊 Synchronising {self.name}s.") diff = await self._get_diff(guild) - total = sum(map(len, diff)) + totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} - if total > self.MAX_DIFF and not await self._confirm(ctx): + if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(ctx): return # Sync aborted. await self._sync(diff) - results = ", ".join(f"{name} `{len(total)}`" for name, total in diff._asdict().items()) + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) log.info(f"{self.name} syncer finished: {results}.") if ctx: await message.edit( @@ -237,7 +237,7 @@ class UserSyncer(Syncer): new_user = guild_users[user_id] users_to_create.add(new_user) - return _Diff(users_to_create, users_to_update) + return _Diff(users_to_create, users_to_update, None) async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" -- cgit v1.2.3 From 7b10c5b81f5016e7e9f3f60da247cf075326d370 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 11:42:02 -0800 Subject: Sync: fix missing await for fetch_channel --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 8b9fe1ad9..d9010ce3f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -53,7 +53,7 @@ class Syncer(abc.ABC): if not channel: try: - channel = self.bot.fetch_channel(constants.Channels.devcore) + channel = await self.bot.fetch_channel(constants.Channels.devcore) except HTTPException: log.exception( f"Failed to fetch channel for sending sync confirmation prompt; " -- cgit v1.2.3 From 919431fddfd2f392cf549177f1d4743c76034951 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:11:19 -0800 Subject: Sync: fix passing context instead of message to _confirm() * Mention possibility of timing out as a reason for aborting a sync --- bot/cogs/sync/syncers.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index d9010ce3f..1465730c1 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -92,8 +92,8 @@ class Syncer(abc.ABC): await message.edit(content=f':ok_hand: {self.name} sync will proceed.') return True else: - log.warning(f"{self.name} syncer aborted!") - await message.edit(content=f':x: {self.name} sync aborted!') + log.warning(f"{self.name} syncer aborted or timed out!") + await message.edit(content=f':x: {self.name} sync aborted or timed out!') return False @abc.abstractmethod @@ -115,20 +115,21 @@ class Syncer(abc.ABC): optionally redirect to `ctx` instead. """ log.info(f"Starting {self.name} syncer.") + message = None if ctx: message = await ctx.send(f"📊 Synchronising {self.name}s.") diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} - if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(ctx): + if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(message): return # Sync aborted. await self._sync(diff) results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) log.info(f"{self.name} syncer finished: {results}.") - if ctx: + if message: await message.edit( content=f":ok_hand: Synchronisation of {self.name}s complete: {results}" ) -- cgit v1.2.3 From f0c6a34be439788de18872c6edbc1d94256bda14 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:13:05 -0800 Subject: Sync: fix overwriting message with None after editing it --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 1465730c1..5652872f7 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -63,7 +63,7 @@ class Syncer(abc.ABC): message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") else: - message = await message.edit(content=f"{message.author.mention} {msg_content}") + await message.edit(content=f"{message.author.mention} {msg_content}") # Add the initial reactions. for emoji in allowed_emoji: -- cgit v1.2.3 From 9e859302ecf2a4d0fd092b21c24ba03401821c0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:15:23 -0800 Subject: Sync: remove author mention from confirm prompt --- bot/cogs/sync/syncers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 5652872f7..ceb046b3e 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -43,7 +43,7 @@ class Syncer(abc.ABC): allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) msg_content = ( f'Possible cache issue while syncing {self.name}s. ' - f'Found no {self.name}s or more than {self.MAX_DIFF} {self.name}s were changed. ' + f'More than {self.MAX_DIFF} {self.name}s were changed. ' f'React to confirm or abort the sync.' ) @@ -63,7 +63,7 @@ class Syncer(abc.ABC): message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") else: - await message.edit(content=f"{message.author.mention} {msg_content}") + await message.edit(content=msg_content) # Add the initial reactions. for emoji in allowed_emoji: -- cgit v1.2.3 From 9db9fd85e0c12e365c1834812584f4b16862a457 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 12:18:51 -0800 Subject: Sync: fix confirmation reaction check * Ignore bot reactions * Check for core dev role if sync is automatic * Require author as an argument to _confirm() so it can be compared against the reaction author --- bot/cogs/sync/syncers.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index ceb046b3e..2bf551bc7 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -3,7 +3,7 @@ import logging import typing as t from collections import namedtuple -from discord import Guild, HTTPException, Message +from discord import Guild, HTTPException, Member, Message from discord.ext.commands import Context from bot import constants @@ -33,7 +33,7 @@ class Syncer(abc.ABC): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError - async def _confirm(self, message: t.Optional[Message] = None) -> bool: + async def _confirm(self, author: Member, message: t.Optional[Message] = None) -> bool: """ Send a prompt to confirm or abort a sync using reactions and return True if confirmed. @@ -70,10 +70,12 @@ class Syncer(abc.ABC): await message.add_reaction(emoji) def check(_reaction, user): # noqa: TYP - # Skip author check for auto syncs + # 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) return ( _reaction.message.id == message.id - and True if message.author.bot else user == message.author + and not user.bot + and has_role if author.bot else user == author and str(_reaction.emoji) in allowed_emoji ) @@ -115,14 +117,17 @@ class Syncer(abc.ABC): optionally redirect to `ctx` instead. """ log.info(f"Starting {self.name} syncer.") + message = None + author = self.bot.user if ctx: message = await ctx.send(f"📊 Synchronising {self.name}s.") + author = ctx.author diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} - if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(message): + if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(author, message): return # Sync aborted. await self._sync(diff) -- cgit v1.2.3 From e1c4471e4497db8918d27195ed4485893bc1b4e9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 13:49:35 -0800 Subject: Sync: add trace and debug logging --- bot/cogs/sync/syncers.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2bf551bc7..08da569d8 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -40,6 +40,8 @@ class Syncer(abc.ABC): If a message is given, it is edited to display the prompt and reactions. Otherwise, a new message is sent to the dev-core channel and mentions the core developers role. """ + log.trace(f"Sending {self.name} sync confirmation prompt.") + allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) msg_content = ( f'Possible cache issue while syncing {self.name}s. ' @@ -49,9 +51,11 @@ 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) 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) except HTTPException: @@ -66,6 +70,7 @@ class Syncer(abc.ABC): await message.edit(content=msg_content) # Add the initial reactions. + log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") for emoji in allowed_emoji: await message.add_reaction(emoji) @@ -81,6 +86,7 @@ class Syncer(abc.ABC): reaction = None try: + log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") reaction, _ = await self.bot.wait_for( 'reaction_add', check=check, @@ -88,9 +94,10 @@ class Syncer(abc.ABC): ) except TimeoutError: # reaction will remain none thus sync will be aborted in the finally block below. - pass + log.debug(f"The {self.name} syncer confirmation prompt timed out.") finally: if str(reaction) == constants.Emojis.check_mark: + log.trace(f"The {self.name} syncer was confirmed.") await message.edit(content=f':ok_hand: {self.name} sync will proceed.') return True else: @@ -127,6 +134,7 @@ class Syncer(abc.ABC): diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} + log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(author, message): return # Sync aborted. @@ -147,6 +155,7 @@ class RoleSyncer(Syncer): async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of roles between the cache of `guild` and the database.""" + log.trace("Getting the diff for roles.") roles = await self.bot.api_client.get('bot/roles') # Pack DB roles and guild roles into one common, hashable format. @@ -178,12 +187,15 @@ class RoleSyncer(Syncer): async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the role cache of `guild`.""" + log.trace("Syncing created roles...") for role in diff.created: await self.bot.api_client.post('bot/roles', json={**role._asdict()}) + log.trace("Syncing updated roles...") for role in diff.updated: await self.bot.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) + log.trace("Syncing deleted roles...") for role in diff.deleted: await self.bot.api_client.delete(f'bot/roles/{role.id}') @@ -195,6 +207,7 @@ class UserSyncer(Syncer): async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of users between the cache of `guild` and the database.""" + log.trace("Getting the diff for users.") users = await self.bot.api_client.get('bot/users') # Pack DB roles and guild roles into one common, hashable format. @@ -247,8 +260,10 @@ class UserSyncer(Syncer): async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" + log.trace("Syncing created users...") for user in diff.created: await self.bot.api_client.post('bot/users', json={**user._asdict()}) + log.trace("Syncing updated users...") for user in diff.updated: await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) -- cgit v1.2.3 From bba4319f1e9dbad3c4c0a112252d1a0836f5cbc3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Dec 2019 13:53:08 -0800 Subject: Sync: keep the mention for all edits of the confirmation prompt This makes it clearer to users where the notification came from. --- bot/cogs/sync/syncers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 08da569d8..2ba9a2a3a 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -43,6 +43,7 @@ class Syncer(abc.ABC): log.trace(f"Sending {self.name} sync confirmation prompt.") allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + mention = "" msg_content = ( f'Possible cache issue while syncing {self.name}s. ' f'More than {self.MAX_DIFF} {self.name}s were changed. ' @@ -65,7 +66,8 @@ class Syncer(abc.ABC): ) return False - message = await channel.send(f"<@&{constants.Roles.core_developer}> {msg_content}") + mention = f"<@&{constants.Roles.core_developer}> " + message = await channel.send(f"{mention}{msg_content}") else: await message.edit(content=msg_content) @@ -98,11 +100,11 @@ class Syncer(abc.ABC): finally: if str(reaction) == constants.Emojis.check_mark: log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {self.name} sync will proceed.') + await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') return True else: - log.warning(f"{self.name} syncer aborted or timed out!") - await message.edit(content=f':x: {self.name} sync aborted or timed out!') + log.warning(f"The {self.name} syncer was aborted or timed out!") + await message.edit(content=f':x: {mention}{self.name} sync aborted or timed out!') return False @abc.abstractmethod -- cgit v1.2.3 From ed8dbbae70ae00c9ee6596dffccfca8f0b78c003 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Dec 2019 20:08:04 -0800 Subject: Sync: split _confirm() into two functions One is responsible for sending the confirmation prompt while the other waits for the reaction. The split allows for the confirmation prompt to be edited with the results of automatic syncs too. --- bot/cogs/sync/syncers.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2ba9a2a3a..2376a3f6f 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -21,6 +21,7 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" + _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) CONFIRM_TIMEOUT = 60 * 5 # 5 minutes MAX_DIFF = 10 @@ -33,17 +34,16 @@ class Syncer(abc.ABC): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError - async def _confirm(self, author: Member, message: t.Optional[Message] = None) -> bool: + async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: """ - Send a prompt to confirm or abort a sync using reactions and return True if confirmed. + Send a prompt to confirm or abort a sync using reactions and return the sent message. If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. + message is sent to the dev-core channel and mentions the core developers role. If the + channel cannot be retrieved, return None. """ log.trace(f"Sending {self.name} sync confirmation prompt.") - allowed_emoji = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - mention = "" msg_content = ( f'Possible cache issue while syncing {self.name}s. ' f'More than {self.MAX_DIFF} {self.name}s were changed. ' @@ -64,7 +64,7 @@ class Syncer(abc.ABC): f"Failed to fetch channel for sending sync confirmation prompt; " f"aborting {self.name} sync." ) - return False + return None mention = f"<@&{constants.Roles.core_developer}> " message = await channel.send(f"{mention}{msg_content}") @@ -73,9 +73,19 @@ class Syncer(abc.ABC): # Add the initial reactions. log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in allowed_emoji: + for emoji in self._REACTION_EMOJIS: await message.add_reaction(emoji) + return message + + async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: + """ + Wait for a confirmation reaction by `author` on `message` and return True if confirmed. + + If `author` is a bot user, then anyone with the core developers role may react to confirm. + If there is no reaction within `CONFIRM_TIMEOUT` seconds, return False. To acknowledge the + reaction (or lack thereof), `message` will be edited. + """ def check(_reaction, user): # noqa: TYP # 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) @@ -83,9 +93,15 @@ class Syncer(abc.ABC): _reaction.message.id == message.id and not user.bot and has_role if author.bot else user == author - and str(_reaction.emoji) in allowed_emoji + and str(_reaction.emoji) in self._REACTION_EMOJIS ) + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = "" + if message.role_mentions: + mention = message.role_mentions[0].mention + reaction = None try: log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") @@ -137,8 +153,14 @@ class Syncer(abc.ABC): totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if sum(totals.values()) > self.MAX_DIFF and not await self._confirm(author, message): - return # Sync aborted. + if sum(totals.values()) > self.MAX_DIFF: + message = await self._send_prompt(message) + if not message: + return # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return # Sync aborted. await self._sync(diff) -- cgit v1.2.3 From 144a805704fb9948c15a78cd7e4cbc97aa3a8dd1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 26 Dec 2019 20:40:20 -0800 Subject: Sync: mention core devs when results are shown & fix missing space --- bot/cogs/sync/syncers.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2376a3f6f..bebea8f19 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -21,7 +21,9 @@ _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}> " _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) + CONFIRM_TIMEOUT = 60 * 5 # 5 minutes MAX_DIFF = 10 @@ -66,8 +68,7 @@ class Syncer(abc.ABC): ) return None - mention = f"<@&{constants.Roles.core_developer}> " - message = await channel.send(f"{mention}{msg_content}") + message = await channel.send(f"{self._CORE_DEV_MENTION}{msg_content}") else: await message.edit(content=msg_content) @@ -98,9 +99,7 @@ class Syncer(abc.ABC): # Preserve the core-dev role mention in the message edits so users aren't confused about # where notifications came from. - mention = "" - if message.role_mentions: - mention = message.role_mentions[0].mention + mention = self._CORE_DEV_MENTION if author.bot else "" reaction = None try: @@ -167,8 +166,11 @@ class Syncer(abc.ABC): results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) log.info(f"{self.name} syncer finished: {results}.") if message: + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" await message.edit( - content=f":ok_hand: Synchronisation of {self.name}s complete: {results}" + content=f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" ) -- cgit v1.2.3 From 6c1164fe1bf95d49373722051a00f11e0f17a699 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Dec 2019 14:37:58 -0800 Subject: Sync: handle API errors gracefully The whole sync is aborted when an error is caught for simplicity's sake. The sync message is edited to display the error and the traceback is logged. To distinguish an error from an abort/timeout, the latter now uses a warning emoji while the former uses the red cross. --- bot/cogs/sync/syncers.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index bebea8f19..4286609da 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -7,6 +7,7 @@ from discord import Guild, HTTPException, Member, Message from discord.ext.commands import Context from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot log = logging.getLogger(__name__) @@ -119,7 +120,9 @@ class Syncer(abc.ABC): return True else: log.warning(f"The {self.name} syncer was aborted or timed out!") - await message.edit(content=f':x: {mention}{self.name} sync aborted or timed out!') + await message.edit( + content=f':warning: {mention}{self.name} sync aborted or timed out!' + ) return False @abc.abstractmethod @@ -161,17 +164,25 @@ class Syncer(abc.ABC): if not confirmed: return # Sync aborted. - await self._sync(diff) + # Preserve the core-dev role mention in the message edits so users aren't confused about + # where notifications came from. + mention = self._CORE_DEV_MENTION if author.bot else "" + + try: + await self._sync(diff) + except ResponseCodeError as e: + log.exception(f"{self.name} syncer failed!") + + # Don't show response text because it's probably some really long HTML. + results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" + content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + else: + results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + log.info(f"{self.name} syncer finished: {results}.") + content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) - log.info(f"{self.name} syncer finished: {results}.") if message: - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - await message.edit( - content=f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" - ) + await message.edit(content=content) class RoleSyncer(Syncer): -- cgit v1.2.3 From d9407a56ba34f3a446f3fa583c0c4dec107913dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 31 Dec 2019 12:19:44 -0800 Subject: Tests: add a MockAPIClient --- tests/helpers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(+) 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(-) 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(-) 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(+) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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(-) 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 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(+) 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(+) 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(+) 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(+) 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(+) 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(+) 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(+) 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(+) 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(+) 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(-) 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(-) 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(-) 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(-) 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(-) 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 7af3d589f51cfabe30d47415baad4420983f53ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 9 Jan 2020 11:18:36 -0800 Subject: Sync: make the reaction check an instance method instead of nested The function will be easier to test if it's separate rather than nested. --- bot/cogs/sync/syncers.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 4286609da..e7465d31d 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -2,8 +2,9 @@ import abc import logging import typing as t from collections import namedtuple +from functools import partial -from discord import Guild, HTTPException, Member, Message +from discord import Guild, HTTPException, Member, Message, Reaction, User from discord.ext.commands import Context from bot import constants @@ -80,24 +81,38 @@ class Syncer(abc.ABC): return message + def _reaction_check( + self, + author: Member, + message: Message, + reaction: Reaction, + user: t.Union[Member, User] + ) -> bool: + """ + Return True if the `reaction` is a valid confirmation or abort reaction on `message`. + + If the `author` of the prompt is a bot, then a reaction by any core developer will be + considered valid. Otherwise, the author of the reaction (`user`) will have to be the + `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) + return ( + reaction.message.id == message.id + and not user.bot + and has_role if author.bot else user == author + and str(reaction.emoji) in self._REACTION_EMOJIS + ) + async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: """ Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - If `author` is a bot user, then anyone with the core developers role may react to confirm. + 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. """ - def check(_reaction, user): # noqa: TYP - # 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) - return ( - _reaction.message.id == message.id - and not user.bot - and has_role if author.bot else user == author - and str(_reaction.emoji) in self._REACTION_EMOJIS - ) - # Preserve the core-dev role mention in the message edits so users aren't confused about # where notifications came from. mention = self._CORE_DEV_MENTION if author.bot else "" @@ -107,7 +122,7 @@ class Syncer(abc.ABC): log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") reaction, _ = await self.bot.wait_for( 'reaction_add', - check=check, + check=partial(self._reaction_check, author, message), timeout=self.CONFIRM_TIMEOUT ) except TimeoutError: -- 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(-) 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(+) 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(+) 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 13b8c7f4143d5dbc25e07f52fe64bc7a1079ab68 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Jan 2020 09:52:37 -0800 Subject: Sync: fix precedence of conditional expression in _reaction_check --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e7465d31d..6c95b58ad 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -100,7 +100,7 @@ class Syncer(abc.ABC): return ( reaction.message.id == message.id and not user.bot - and has_role if author.bot else user == author + and (has_role if author.bot else user == author) and str(reaction.emoji) in self._REACTION_EMOJIS ) -- 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(-) 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(+) 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(+) 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(-) 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(-) 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 81716ef72632844e0cf2f33982bbe71cf4b29d7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jan 2020 11:11:04 -0800 Subject: Sync: create a separate function to get the confirmation result --- bot/cogs/sync/syncers.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6c95b58ad..e6faca661 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -150,6 +150,34 @@ class Syncer(abc.ABC): """Perform the API calls for synchronisation.""" raise NotImplementedError + async def _get_confirmation_result( + self, + diff_size: int, + author: Member, + message: t.Optional[Message] = None + ) -> t.Tuple[bool, t.Optional[Message]]: + """ + 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. + + 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: + message = await self._send_prompt(message) + if not message: + return False, None # Couldn't get channel. + + confirmed = await self._wait_for_confirmation(author, message) + if not confirmed: + return False, message # Sync aborted. + + return True, message + async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: """ Synchronise the database with the cache of `guild`. @@ -168,16 +196,11 @@ class Syncer(abc.ABC): diff = await self._get_diff(guild) totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} + diff_size = sum(totals.values()) - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if sum(totals.values()) > self.MAX_DIFF: - message = await self._send_prompt(message) - if not message: - return # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return # Sync aborted. + confirmed, message = await self._get_confirmation_result(diff_size, author, message) + if not confirmed: + return # Preserve the core-dev role mention in the message edits so users aren't confused about # where notifications came from. -- 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(-) 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(+) 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(+) 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(-) 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(-) 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(-) 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(+) 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(+) 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 2a8c545a4d3d39a9d9659b607872c7f5653051ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 25 Jan 2020 18:42:12 -0800 Subject: Sync tests: ignore coverage for abstract methods It's impossible to create an instance of the base class which does not have the abstract methods implemented, so it doesn't really matter what they do. --- bot/cogs/sync/syncers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e6faca661..23039d1fc 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -36,7 +36,7 @@ class Syncer(abc.ABC): @abc.abstractmethod def name(self) -> str: """The name of the syncer; used in output messages and logging.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: """ @@ -143,12 +143,12 @@ class Syncer(abc.ABC): @abc.abstractmethod async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover @abc.abstractmethod async def _sync(self, diff: _Diff) -> None: """Perform the API calls for synchronisation.""" - raise NotImplementedError + raise NotImplementedError # pragma: no cover async def _get_confirmation_result( self, -- 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 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(+) 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(-) 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(-) 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(-) 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(+) 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(+) 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(-) 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(-) 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(+) 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(+) 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(+) 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(-) 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(-) 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(+) 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(+) 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(+) 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(+) 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(-) 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(-) 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(+) 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(+) 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 548f258314513cc41a0e4339b6eaa06be75a8f5d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 7 Feb 2020 11:30:52 -0800 Subject: Sync: only update in_guild field when a member leaves The member and user update listeners should already be detecting and updating other fields so by the time a user leaves, the rest of the fields should be up-to-date. * Dedent condition which was indented too far --- bot/cogs/sync/cog.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 66ffbabf9..ee3cccbfa 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -66,10 +66,10 @@ class Sync(Cog): async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" if ( - before.name != after.name - or before.colour != after.colour - or before.permissions != after.permissions - or before.position != after.position + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position ): await self.bot.api_client.put( f'bot/roles/{after.id}', @@ -120,18 +120,8 @@ class Sync(Cog): @Cog.listener() async def on_member_remove(self, member: Member) -> None: - """Updates the user information when a member leaves the guild.""" - await self.bot.api_client.put( - f'bot/users/{member.id}', - json={ - '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) - } - ) + """Set the in_guild field to False when a member leaves the guild.""" + await self.patch_user(member.id, updated_information={"in_guild": False}) @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: -- 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(+) 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(-) 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(+) 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(-) 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(-) 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(+) 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(-) 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(-) 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 bc932aa09848bc10683d66b7e7d9f6054e9958c6 Mon Sep 17 00:00:00 2001 From: Deniz Date: Wed, 12 Feb 2020 20:33:31 +0100 Subject: Use collections.Counter properly. Use the ChannelType enum instead of the __class__ attribute, and re-add the None check for !user roles. --- bot/cogs/information.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index efe660851..2461cc749 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -5,7 +5,7 @@ import textwrap from collections import Counter, defaultdict from typing import Any, Mapping, Optional, Union -from discord import CategoryChannel, Colour, Embed, Member, Message, Role, Status, TextChannel, VoiceChannel, utils +from discord import Colour, Embed, Member, Message, Role, Status, utils from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group from discord.utils import escape_markdown @@ -104,14 +104,11 @@ class Information(Cog): member_count = ctx.guild.member_count # How many of each type of channel? - channels = Counter({TextChannel: 0, VoiceChannel: 0, CategoryChannel: 0}) - for channel in ctx.guild.channels: - channels[channel.__class__] += 1 + channels = Counter(c.type for c in ctx.guild.channels) + channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]} \n" for ch in channels)).strip() # How many of each user status? - statuses = Counter({status: 0 for status in Status}) - for member in ctx.guild.members: - statuses[member.status] += 1 + statuses = Counter(member.status for member in ctx.guild.members) embed = Embed( colour=Colour.blurple(), @@ -124,9 +121,7 @@ class Information(Cog): **Counts** Members: {member_count:,} Roles: {roles} - Text Channels: {channels[TextChannel]} - Voice Channels: {channels[VoiceChannel]} - Channel categories: {channels[CategoryChannel]} + {channel_counts} **Members** {constants.Emojis.status_online} {statuses[Status.online]} @@ -135,7 +130,6 @@ class Information(Cog): {constants.Emojis.status_offline} {statuses[Status.offline]} """) ) - embed.set_thumbnail(url=ctx.guild.icon_url) await ctx.send(embed=embed) @@ -191,7 +185,7 @@ class Information(Cog): {custom_status} **Member Information** Joined: {joined} - Roles: {roles} + Roles: {roles or None} """).strip() ] -- cgit v1.2.3 From 419a8e616e6e5a185769764e755ce0592ef8e72f Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 12 Feb 2020 15:56:56 -0500 Subject: Add reminder ID to footer of confirmation message --- bot/cogs/reminders.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 45bf9a8f4..7b2f8d31d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -56,12 +56,14 @@ class Reminders(Scheduler, Cog): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, on_success: str) -> None: + async def _send_confirmation(ctx: Context, on_success: str, reminder_id: str) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = Embed() embed.colour = Colour.green() embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success + embed.set_footer(text=f"ID {reminder_id}") + await ctx.send(embed=embed) async def _scheduled_task(self, reminder: dict) -> None: @@ -182,7 +184,8 @@ class Reminders(Scheduler, Cog): # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!" + on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!", + reminder_id=reminder["id"], ) loop = asyncio.get_event_loop() @@ -261,7 +264,7 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!" + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ ) await self._reschedule_reminder(reminder) @@ -277,7 +280,7 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!" + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ ) await self._reschedule_reminder(reminder) @@ -286,7 +289,7 @@ class Reminders(Scheduler, Cog): """Delete one of your active reminders.""" await self._delete_reminder(id_) await self._send_confirmation( - ctx, on_success="That reminder has been deleted successfully!" + ctx, on_success="That reminder has been deleted successfully!", reminder_id=id_ ) -- cgit v1.2.3 From b1c1f8c11ec09d264afa8095fa6eb13639685bc9 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 12 Feb 2020 16:52:39 -0500 Subject: Add reminder target datetime to footer of confirmation message --- bot/cogs/reminders.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 7b2f8d31d..715c2d89b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -56,13 +56,20 @@ class Reminders(Scheduler, Cog): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, on_success: str, reminder_id: str) -> None: + async def _send_confirmation( + ctx: Context, on_success: str, reminder_id: str, delivery_dt: Optional[datetime] + ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = Embed() embed.colour = Colour.green() embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success - embed.set_footer(text=f"ID {reminder_id}") + + if delivery_dt: + embed.set_footer(text=f"ID: {reminder_id}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}") + else: + # Reminder deletion will have a `None` `delivery_dt` + embed.set_footer(text=f"ID: {reminder_id}") await ctx.send(embed=embed) @@ -186,6 +193,7 @@ class Reminders(Scheduler, Cog): ctx, on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!", reminder_id=reminder["id"], + delivery_dt=expiration, ) loop = asyncio.get_event_loop() @@ -264,7 +272,7 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, delivery_dt=expiration ) await self._reschedule_reminder(reminder) @@ -278,9 +286,12 @@ class Reminders(Scheduler, Cog): json={'content': content} ) + # Parse the reminder expiration back into a datetime for the confirmation message + expiration = datetime.fromisoformat(reminder['expiration'][:-1]) + # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!", reminder_id=id_ + ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, delivery_dt=expiration ) await self._reschedule_reminder(reminder) @@ -289,7 +300,7 @@ class Reminders(Scheduler, Cog): """Delete one of your active reminders.""" await self._delete_reminder(id_) await self._send_confirmation( - ctx, on_success="That reminder has been deleted successfully!", reminder_id=id_ + ctx, on_success="That reminder has been deleted successfully!", reminder_id=id_, delivery_dt=None ) -- cgit v1.2.3 From ee930bdde1e99cb9e2880e86dd647a42e90d2580 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 12 Feb 2020 17:14:49 -0500 Subject: Expand reminder channel whitelist to dev-contrib for non-staff Add channel ID to config files --- bot/cogs/reminders.py | 2 +- bot/constants.py | 1 + config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 715c2d89b..57a74270a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -20,7 +20,7 @@ from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) -WHITELISTED_CHANNELS = (Channels.bot,) +WHITELISTED_CHANNELS = (Channels.bot, Channels.devcontrib) MAXIMUM_REMINDERS = 5 diff --git a/bot/constants.py b/bot/constants.py index fe8e57322..e2704bfa8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -365,6 +365,7 @@ class Channels(metaclass=YAMLGetter): bot: int checkpoint_test: int defcon: int + devcontrib: int devlog: int devtest: int esoteric: int diff --git a/config-default.yml b/config-default.yml index fda14b511..ab610d618 100644 --- a/config-default.yml +++ b/config-default.yml @@ -121,6 +121,7 @@ guild: bot: 267659945086812160 checkpoint_test: 422077681434099723 defcon: &DEFCON 464469101889454091 + devcontrib: 635950537262759947 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 esoteric: 470884583684964352 -- 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(-) 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 b26122d72c1a41a4919c95642c6bc16e82d535a3 Mon Sep 17 00:00:00 2001 From: Deniz Date: Thu, 13 Feb 2020 17:35:56 +0100 Subject: Add thousand separators to Members count, closes #744 --- bot/cogs/information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 2461cc749..c8b5eb5ad 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -124,10 +124,10 @@ class Information(Cog): {channel_counts} **Members** - {constants.Emojis.status_online} {statuses[Status.online]} - {constants.Emojis.status_idle} {statuses[Status.idle]} - {constants.Emojis.status_dnd} {statuses[Status.dnd]} - {constants.Emojis.status_offline} {statuses[Status.offline]} + {constants.Emojis.status_online} {statuses[Status.online]:,} + {constants.Emojis.status_idle} {statuses[Status.idle]:,} + {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} + {constants.Emojis.status_offline} {statuses[Status.offline]:,} """) ) embed.set_thumbnail(url=ctx.guild.icon_url) -- cgit v1.2.3 From 5086ca94cb45e411f6463fbe338ba0d6b2192be5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 10:38:41 -0500 Subject: Styling & refactors from review * Refactor confirmation embed footer string generation to be more concise * Multiline long method calls * Refactor humanized delta f string generation for readability * Switch from `datetime.isoformat` to `dateutils.parser.isoparse` to align with changes elsewhere in the codebase (should be more robust) * Shift reminder channel whitelist to constants Co-Authored-By: Mark --- bot/cogs/reminders.py | 39 +++++++++++++++++++++++++-------------- bot/constants.py | 2 +- config-default.yml | 5 +++-- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 57a74270a..efeafa0bc 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -6,12 +6,13 @@ from datetime import datetime, timedelta from operator import itemgetter from typing import Optional +from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta from discord import Colour, Embed, Message from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check @@ -20,7 +21,7 @@ from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) -WHITELISTED_CHANNELS = (Channels.bot, Channels.devcontrib) +WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -45,13 +46,12 @@ class Reminders(Scheduler, Cog): loop = asyncio.get_event_loop() for reminder in response: - remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) + remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) # If the reminder is already overdue ... if remind_at < now: late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) - else: self.schedule_task(loop, reminder["id"], reminder) @@ -65,18 +65,19 @@ class Reminders(Scheduler, Cog): embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success + footer_str = f"ID: {reminder_id}" if delivery_dt: - embed.set_footer(text=f"ID: {reminder_id}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}") - else: # Reminder deletion will have a `None` `delivery_dt` - embed.set_footer(text=f"ID: {reminder_id}") + footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}" + + embed.set_footer(text=footer_str) await ctx.send(embed=embed) async def _scheduled_task(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] - reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1]) + reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) # Send the reminder message once the desired duration has passed await wait_until(reminder_datetime) @@ -187,11 +188,12 @@ class Reminders(Scheduler, Cog): ) now = datetime.utcnow() - timedelta(seconds=1) + humanized_delta = humanize_delta(relativedelta(expiration, now)) # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!", + on_success=f"Your reminder will arrive in {humanized_delta}!", reminder_id=reminder["id"], delivery_dt=expiration, ) @@ -223,7 +225,7 @@ class Reminders(Scheduler, Cog): for content, remind_at, id_ in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = datetime.fromisoformat(remind_at[:-1]) + remind_datetime = isoparse(remind_at).replace(tzinfo=None) time = humanize_delta(relativedelta(remind_datetime, now)) text = textwrap.dedent(f""" @@ -272,7 +274,10 @@ class Reminders(Scheduler, Cog): # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, delivery_dt=expiration + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, ) await self._reschedule_reminder(reminder) @@ -287,11 +292,14 @@ class Reminders(Scheduler, Cog): ) # Parse the reminder expiration back into a datetime for the confirmation message - expiration = datetime.fromisoformat(reminder['expiration'][:-1]) + expiration = isoparse(reminder['expiration']).replace(tzinfo=None) # Send a confirmation message to the channel await self._send_confirmation( - ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, delivery_dt=expiration + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, ) await self._reschedule_reminder(reminder) @@ -300,7 +308,10 @@ class Reminders(Scheduler, Cog): """Delete one of your active reminders.""" await self._delete_reminder(id_) await self._send_confirmation( - ctx, on_success="That reminder has been deleted successfully!", reminder_id=id_, delivery_dt=None + ctx, + on_success="That reminder has been deleted successfully!", + reminder_id=id_, + delivery_dt=None, ) diff --git a/bot/constants.py b/bot/constants.py index e2704bfa8..e9990307a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -433,7 +433,7 @@ class Guild(metaclass=YAMLGetter): id: int ignored: List[int] staff_channels: List[int] - + reminder_whitelist: List[int] class Keys(metaclass=YAMLGetter): section = "keys" diff --git a/config-default.yml b/config-default.yml index ab610d618..3de7c6ba4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -118,10 +118,10 @@ guild: announcements: 354619224620138496 attachment_log: &ATTCH_LOG 649243850006855680 big_brother_logs: &BBLOGS 468507907357409333 - bot: 267659945086812160 + bot: &BOT_CMD 267659945086812160 checkpoint_test: 422077681434099723 defcon: &DEFCON 464469101889454091 - devcontrib: 635950537262759947 + devcontrib: &DEV_CONTRIB 635950537262759947 devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 esoteric: 470884583684964352 @@ -156,6 +156,7 @@ guild: staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG] + reminder_whitelist: [*BOT_CMD, *DEV_CONTRIB] roles: admin: &ADMIN_ROLE 267628507062992896 -- cgit v1.2.3 From d2451bd8fd3e3efc43de7146958d5f9f7d90723d Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 11:20:56 -0500 Subject: Add full capture of reason string to superstarify invocation --- bot/cogs/moderation/superstarify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 050c847ac..c41874a95 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -109,7 +109,8 @@ class Superstarify(InfractionScheduler, Cog): ctx: Context, member: Member, duration: Expiry, - reason: str = None + *, + reason: str = None, ) -> None: """ Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. -- cgit v1.2.3 From e82ccfe032a6637a064c41e4b7b66107a84e0b36 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 11:24:17 -0500 Subject: Add "cancel" as a reminder delete alias --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index efeafa0bc..f39ad856a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -303,7 +303,7 @@ class Reminders(Scheduler, Cog): ) await self._reschedule_reminder(reminder) - @remind_group.command("delete", aliases=("remove",)) + @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self._delete_reminder(id_) -- cgit v1.2.3 From bad164b8af9e0db0d5d8b1beaa8f2e6e3fdc4799 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 15 Feb 2020 11:37:23 -0500 Subject: Add missed signature reformat from review Co-Authored-By: Mark --- bot/cogs/reminders.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index f39ad856a..ff803baf8 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -57,7 +57,10 @@ class Reminders(Scheduler, Cog): @staticmethod async def _send_confirmation( - ctx: Context, on_success: str, reminder_id: str, delivery_dt: Optional[datetime] + ctx: Context, + on_success: str, + reminder_id: str, + delivery_dt: Optional[datetime], ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = Embed() -- cgit v1.2.3 From b4d870add68fafa00420a1d75136b6264be3ff0a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 18:22:30 -0800 Subject: Utils: refactor format_infraction_with_duration and support absolute val The absolute value is useful for preventing negative values in the case that date_to is in the past relative to date_from. * Add an absolute parameter to take the absolute value of the duration * Rename expiry to date_to * Rewrite the docstring --- bot/utils/time.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index 7416f36e0..77060143c 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -114,30 +114,40 @@ def format_infraction(timestamp: str) -> str: def format_infraction_with_duration( - expiry: Optional[str], + date_to: Optional[str], date_from: Optional[datetime.datetime] = None, - max_units: int = 2 + max_units: int = 2, + absolute: bool = True ) -> Optional[str]: """ - Format an infraction timestamp to a more readable ISO 8601 format WITH the duration. + Return `date_to` formatted as a readable ISO-8601 with the humanized duration since `date_from`. - Returns a human-readable version of the duration between datetime.utcnow() and an expiry. - Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. - `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - By default, max_units is 2. + `date_from` must be an ISO-8601 formatted timestamp. The duration is calculated as from + `date_from` until `date_to` with a precision of seconds. If `date_from` is unspecified, the + current time is used. + + `max_units` specifies the maximum number of units of time to include in the duration. For + example, a value of 1 may include days but not hours. + + If `absolute` is True, the absolute value of the duration delta is used. This prevents negative + values in the case that `date_to` is in the past relative to `date_from`. """ - if not expiry: + if not date_to: return None + date_to_formatted = format_infraction(date_to) + date_from = date_from or datetime.datetime.utcnow() - date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) + date_to = dateutil.parser.isoparse(date_to).replace(tzinfo=None, microsecond=0) - expiry_formatted = format_infraction(expiry) + delta = relativedelta(date_to, date_from) + if absolute: + delta = abs(delta) - duration = humanize_delta(relativedelta(date_to, date_from), max_units=max_units) - duration_formatted = f" ({duration})" if duration else '' + duration = humanize_delta(delta, max_units=max_units) + duration_formatted = f" ({duration})" if duration else "" - return f"{expiry_formatted}{duration_formatted}" + return f"{date_to_formatted}{duration_formatted}" def until_expiration( -- cgit v1.2.3 From 2a655699b5e387a3854472210c5bf6ee95d076cc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 18:40:54 -0800 Subject: Moderation: add creation date & duration to expired infraction log Closes #685 --- bot/cogs/moderation/scheduler.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e14c302cb..6a0ed71a0 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -311,6 +311,11 @@ class InfractionScheduler(Scheduler): user_id = infraction["user"] type_ = infraction["type"] id_ = infraction["id"] + inserted_at = infraction["inserted_at"] + expiry = infraction["expires_at"] + + expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None + created = time.format_infraction_with_duration(inserted_at, expiry) log.info(f"Marking infraction #{id_} as inactive (expired).") @@ -318,7 +323,8 @@ class InfractionScheduler(Scheduler): log_text = { "Member": str(user_id), "Actor": str(self.bot.user), - "Reason": infraction["reason"] + "Reason": infraction["reason"], + "Created": created, } try: -- cgit v1.2.3 From 9f3bcc57d06db73b0aeeeb0959431531ceafd46a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 19:00:20 -0800 Subject: Moderation: show correct actor in expired infraction log The bot user was always being used instead of using the actor field of the infraction. --- bot/cogs/moderation/scheduler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 6a0ed71a0..e3edc960b 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -309,20 +309,21 @@ class InfractionScheduler(Scheduler): guild = self.bot.get_guild(constants.Guild.id) mod_role = guild.get_role(constants.Roles.moderator) user_id = infraction["user"] + actor = infraction["actor"] type_ = infraction["type"] id_ = infraction["id"] inserted_at = infraction["inserted_at"] expiry = infraction["expires_at"] + log.info(f"Marking infraction #{id_} as inactive (expired).") + expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None created = time.format_infraction_with_duration(inserted_at, expiry) - log.info(f"Marking infraction #{id_} as inactive (expired).") - log_content = None log_text = { "Member": str(user_id), - "Actor": str(self.bot.user), + "Actor": str(self.bot.get_user(actor) or actor), "Reason": infraction["reason"], "Created": created, } -- cgit v1.2.3 From 695ea44f314da052f0540127fb360077525483e3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 19:03:13 -0800 Subject: Moderation: show member as a mention in expired infraction log --- bot/cogs/moderation/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e3edc960b..e45f196a4 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -322,7 +322,7 @@ class InfractionScheduler(Scheduler): log_content = None log_text = { - "Member": str(user_id), + "Member": f"<@{user_id}>", "Actor": str(self.bot.get_user(actor) or actor), "Reason": infraction["reason"], "Created": created, -- cgit v1.2.3 From c0bc8d03804739d8ff025f4bf71846b09569b75c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Feb 2020 10:59:37 -0800 Subject: Fix missing Django logs when using Docker Compose Fixed by allocating a pseudo-tty to the web and bot services in Docker Compose. --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 7281c7953..11deceae8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - staff.web ports: - "127.0.0.1:8000:8000" + tty: true depends_on: - postgres environment: @@ -37,6 +38,7 @@ services: volumes: - ./logs:/bot/logs - .:/bot:ro + tty: true depends_on: - web environment: -- cgit v1.2.3 From d955ecc449eee54cfd28504403e569f58707588e Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 17 Feb 2020 16:51:00 +1000 Subject: Change snekbox api url to internal docker domain. NGINX has been dockerised, and proxy passes now reference internal container domains rather than referencing host or external domains. This will have a few extra benefits: - Less external factors involved for resolving the service address - Can work with the same address on development envs - Snekbox can be closed down entirely so it's inaccessible from external networks. --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index fda14b511..3345e6f2a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -302,7 +302,7 @@ urls: paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox - snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" + snekbox_eval_api: "http://snekbox:8060/eval" # Discord API URLs discord_api: &DISCORD_API "https://discordapp.com/api/v7/" -- cgit v1.2.3 From 62d198beb6aff28d25098d85d0236e5895444428 Mon Sep 17 00:00:00 2001 From: F4zi <44242259+F4zi780@users.noreply.github.com> Date: Mon, 17 Feb 2020 12:52:06 +0200 Subject: Pagination migrations - Emoji Data Structure Modified Changed the pagination emoji collection from list to tuple This change was suggested since this collection is constant --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index e82763912..4c2976e17 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -14,7 +14,7 @@ RIGHT_EMOJI = "\u27A1" # [:arrow_right:] LAST_EMOJI = "\u23ED" # [:track_next:] DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:] -PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] +PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI) log = logging.getLogger(__name__) -- cgit v1.2.3 From 66901fc6d12046751bbe2bdf37ea0192cf478607 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Feb 2020 09:13:26 -0800 Subject: Moderation: show avatar in infraction deactivation log --- bot/cogs/moderation/scheduler.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e45f196a4..c0de0e4da 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -391,14 +391,19 @@ class InfractionScheduler(Scheduler): if send_log: log_title = f"expiration failed" if "Failure" in log_text else "expired" + user = self.bot.get_user(user_id) + avatar = user.avatar_url_as(static_format="png") if user else None + log.trace(f"Sending deactivation mod log for infraction #{id_}.") await self.mod_log.send_log_message( icon_url=utils.INFRACTION_ICONS[type_][1], colour=Colours.soft_green, title=f"Infraction {log_title}: {type_}", + thumbnail=avatar, text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=f"ID: {id_}", content=log_content, + ) return log_text -- 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(-) 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 51ce6225e1dce6e909101d5948264615a1e068ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Feb 2020 10:06:15 -0800 Subject: API: add comment explaining class attributes Explain changes caused by 22a55534ef13990815a6f69d361e2a12693075d5. --- bot/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/api.py b/bot/api.py index d5880ba18..1562c7fce 100644 --- a/bot/api.py +++ b/bot/api.py @@ -32,6 +32,8 @@ class ResponseCodeError(ValueError): class APIClient: """Django Site API wrapper.""" + # These are class attributes so they can be seen when being mocked for tests. + # See commit 22a55534ef13990815a6f69d361e2a12693075d5 for details. session: Optional[aiohttp.ClientSession] = None loop: asyncio.AbstractEventLoop = None -- cgit v1.2.3 From ebead84ae0eb62ab64b329dfd7fff61ff5de628d Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 20 Feb 2020 22:40:25 +1000 Subject: Change to explicit logs, remove logmatic. --- Pipfile | 1 - Pipfile.lock | 152 ++++++++++++++++++++++---------------------------------- bot/__init__.py | 83 +++++++------------------------ 3 files changed, 76 insertions(+), 160 deletions(-) diff --git a/Pipfile b/Pipfile index 7fd3efae8..7e63bde66 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,6 @@ name = "pypi" [packages] discord-py = "~=1.3.1" aiodns = "~=2.0" -logmatic-python = "~=0.1" aiohttp = "~=3.5" sphinx = "~=2.2" markdownify = "~=0.4" diff --git a/Pipfile.lock b/Pipfile.lock index bf8ff47e9..cca6e1791 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0a0354a8cbd25b19c61b68f928493a445e737dc6447c97f4c4b52fbf72d887ac" + "sha256": "fe6d96850817a250e3c3601044bf8927c0553b73f4cd5cb228a89f04458a93af" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:a5837277e53755078db3a9e8c45bbca605c8ba9ecba7a02d74a7a1779f444723", - "sha256:fa32e33b4b7d0804dcf439ae6ff24d2f0a83d1ba280ee9f555e647d71d394ff5" + "sha256:4199122a450dffd8303b7857a9d82657bf1487fe329e489520833b40fbe92406", + "sha256:fe85c7456e5c060bce4eb9cffab5b2c4d3c563cb72177977b3556c54c8e3aeb6" ], "index": "pypi", - "version": "==6.4.1" + "version": "==6.5.2" }, "aiodns": { "hashes": [ @@ -52,10 +52,10 @@ }, "aiormq": { "hashes": [ - "sha256:8c215a970133ab5ee7c478decac55b209af7731050f52d11439fe910fa0f9e9d", - "sha256:9210f3389200aee7d8067f6435f4a9eff2d3a30b88beb5eaae406ccc11c0fc01" + "sha256:286e0b0772075580466e45f98f051b9728a9316b9c36f0c14c7bc1409be375b0", + "sha256:7ed7d6df6b57af7f8bce7d1ebcbdfc32b676192e46703e81e9e217316e56b5bd" ], - "version": "==3.2.0" + "version": "==3.2.1" }, "alabaster": { "hashes": [ @@ -164,18 +164,18 @@ }, "fuzzywuzzy": { "hashes": [ - "sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254", - "sha256:6f49de47db00e1c71d40ad16da42284ac357936fa9b66bea1df63fed07122d62" + "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8", + "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993" ], "index": "pypi", - "version": "==0.17.0" + "version": "==0.18.0" }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], - "version": "==2.8" + "version": "==2.9" }, "imagesize": { "hashes": [ @@ -191,13 +191,6 @@ ], "version": "==2.11.1" }, - "logmatic-python": { - "hashes": [ - "sha256:0c15ac9f5faa6a60059b28910db642c3dc7722948c3cc940923f8c9039604342" - ], - "index": "pypi", - "version": "==0.1.7" - }, "lxml": { "hashes": [ "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd", @@ -362,7 +355,8 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", + "sha256:cebad02308a8c080ff0a3d3c9eec420a280dc4341cf8681381b444a176406a8e" ], "version": "==2.19" }, @@ -388,12 +382,6 @@ "index": "pypi", "version": "==2.8.1" }, - "python-json-logger": { - "hashes": [ - "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281" - ], - "version": "==0.1.11" - }, "pytz": { "hashes": [ "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", @@ -420,11 +408,11 @@ }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" ], "index": "pypi", - "version": "==2.22.0" + "version": "==2.23.0" }, "six": { "hashes": [ @@ -449,11 +437,11 @@ }, "sphinx": { "hashes": [ - "sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159", - "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5" + "sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", + "sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9" ], "index": "pypi", - "version": "==2.3.1" + "version": "==2.4.2" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -556,6 +544,13 @@ } }, "develop": { + "appdirs": { + "hashes": [ + "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", + "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" + ], + "version": "==1.4.3" + }, "aspy.yaml": { "hashes": [ "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", @@ -579,10 +574,10 @@ }, "cfgv": { "hashes": [ - "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144", - "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289" + "sha256:04b093b14ddf9fd4d17c53ebfd55582d27b76ed30050193c14e560770c5360eb", + "sha256:f22b426ed59cd2ab2b54ff96608d846c33dfb8766a67f0b4a6ce130ce244414f" ], - "version": "==2.0.1" + "version": "==3.0.0" }, "chardet": { "hashes": [ @@ -636,6 +631,12 @@ "index": "pypi", "version": "==4.5.4" }, + "distlib": { + "hashes": [ + "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" + ], + "version": "==0.3.0" + }, "dodgy": { "hashes": [ "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", @@ -658,6 +659,13 @@ ], "version": "==0.3" }, + "filelock": { + "hashes": [ + "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", + "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + ], + "version": "==3.0.12" + }, "flake8": { "hashes": [ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", @@ -668,11 +676,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:05b85538014c850a86dce7374bb6621c64481c24e35e8e90af1315f4d7a3dbaa", - "sha256:43e5233a76fda002b91a54a7cc4510f099c4bfd6279502ec70164016250eebd1" + "sha256:47705be09c6e56e9e3ac1656e8f5ed70862a4657116dc472f5a56c1bdc5172b1", + "sha256:564702ace354e1059252755be79d082a70ae1851c86044ae1a96d0f5453280e9" ], "index": "pypi", - "version": "==1.1.3" + "version": "==1.2.0" }, "flake8-bugbear": { "hashes": [ @@ -700,11 +708,11 @@ }, "flake8-string-format": { "hashes": [ - "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", - "sha256:774d56103d9242ed968897455ef49b7d6de272000cfa83de5814273a868832f1" + "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2", + "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af" ], "index": "pypi", - "version": "==0.2.3" + "version": "==0.3.0" }, "flake8-tidy-imports": { "hashes": [ @@ -730,18 +738,10 @@ }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" ], - "version": "==2.8" - }, - "importlib-metadata": { - "hashes": [ - "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", - "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" - ], - "markers": "python_version < '3.8'", - "version": "==1.5.0" + "version": "==2.9" }, "mccabe": { "hashes": [ @@ -818,11 +818,11 @@ }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" ], "index": "pypi", - "version": "==2.22.0" + "version": "==2.23.0" }, "safety": { "hashes": [ @@ -853,33 +853,6 @@ ], "version": "==0.10.0" }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "markers": "python_version < '3.8'", - "version": "==1.4.1" - }, "unittest-xml-reporting": { "hashes": [ "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0", @@ -898,17 +871,10 @@ }, "virtualenv": { "hashes": [ - "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3", - "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb" - ], - "version": "==16.7.9" - }, - "zipp": { - "hashes": [ - "sha256:ccc94ed0909b58ffe34430ea5451f07bc0c76467d7081619a454bf5c98b89e28", - "sha256:feae2f18633c32fc71f2de629bfb3bd3c9325cd4419642b1f1da42ee488d9b98" + "sha256:08f3623597ce73b85d6854fb26608a6f39ee9d055c81178dc6583803797f8994", + "sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd" ], - "version": "==2.1.0" + "version": "==20.0.4" } } } diff --git a/bot/__init__.py b/bot/__init__.py index 789ace5c0..923ef517b 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -4,11 +4,8 @@ import sys from logging import Logger, StreamHandler, handlers from pathlib import Path -from logmatic import JsonFormatter - - -logging.TRACE = 5 -logging.addLevelName(logging.TRACE, "TRACE") +TRACE_LEVEL = logging.TRACE = 5 +logging.addLevelName(TRACE_LEVEL, "TRACE") def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: @@ -20,75 +17,29 @@ def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) """ - if self.isEnabledFor(logging.TRACE): - self._log(logging.TRACE, msg, args, **kwargs) + if self.isEnabledFor(TRACE_LEVEL): + self._log(TRACE_LEVEL, msg, args, **kwargs) Logger.trace = monkeypatch_trace -# Set up logging -logging_handlers = [] - -# We can't import this yet, so we have to define it ourselves -DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False - -LOG_DIR = Path("logs") -LOG_DIR.mkdir(exist_ok=True) - -if DEBUG_MODE: - logging_handlers.append(StreamHandler(stream=sys.stdout)) - - json_handler = logging.FileHandler(filename=Path(LOG_DIR, "log.json"), mode="w") - json_handler.formatter = JsonFormatter() - logging_handlers.append(json_handler) -else: - - logfile = Path(LOG_DIR, "bot.log") - megabyte = 1048576 - - filehandler = handlers.RotatingFileHandler(logfile, maxBytes=(megabyte*5), backupCount=7) - logging_handlers.append(filehandler) - - json_handler = logging.StreamHandler(stream=sys.stdout) - json_handler.formatter = JsonFormatter() - logging_handlers.append(json_handler) - - -logging.basicConfig( - format="%(asctime)s Bot: | %(name)33s | %(levelname)8s | %(message)s", - datefmt="%b %d %H:%M:%S", - level=logging.TRACE if DEBUG_MODE else logging.INFO, - handlers=logging_handlers -) - -log = logging.getLogger(__name__) - - -for key, value in logging.Logger.manager.loggerDict.items(): - # Force all existing loggers to the correct level and handlers - # This happens long before we instantiate our loggers, so - # those should still have the expected level - - if key == "bot": - continue - - if not isinstance(value, logging.Logger): - # There might be some logging.PlaceHolder objects in there - continue +DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") - if DEBUG_MODE: - value.setLevel(logging.DEBUG) - else: - value.setLevel(logging.INFO) +log_format = logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s") - for handler in value.handlers.copy(): - value.removeHandler(handler) +stream_handler = StreamHandler(stream=sys.stdout) +stream_handler.setFormatter(log_format) - for handler in logging_handlers: - value.addHandler(handler) +log_file = Path("logs", "bot.log") +log_file.parent.mkdir(exist_ok=True) +file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7) +file_handler.setFormatter(log_format) +root_log = logging.getLogger() +root_log.setLevel(TRACE_LEVEL if DEBUG_MODE else logging.INFO) +root_log.addHandler(stream_handler) +root_log.addHandler(file_handler) -# Silence irrelevant loggers -logging.getLogger("aio_pika").setLevel(logging.ERROR) logging.getLogger("discord").setLevel(logging.ERROR) logging.getLogger("websockets").setLevel(logging.ERROR) +logging.getLogger(__name__).setLevel(TRACE_LEVEL) -- cgit v1.2.3 From 4919bb955fa3804e21084f0c451171b7779f2993 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 20 Feb 2020 23:15:42 +1000 Subject: Update flake8-annotations to v2.0. --- Pipfile | 2 +- Pipfile.lock | 53 +++++++++++++++++++++++++++++++++++++++++++++++------ tox.ini | 6 +++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Pipfile b/Pipfile index 7e63bde66..11d5e7deb 100644 --- a/Pipfile +++ b/Pipfile @@ -22,7 +22,7 @@ urllib3 = ">=1.24.2,<1.25" [dev-packages] coverage = "~=4.5" flake8 = "~=3.7" -flake8-annotations = "~=1.1" +flake8-annotations = "~=2.0" flake8-bugbear = "~=19.8" flake8-docstrings = "~=1.4" flake8-import-order = "~=0.18" diff --git a/Pipfile.lock b/Pipfile.lock index cca6e1791..633e62887 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "fe6d96850817a250e3c3601044bf8927c0553b73f4cd5cb228a89f04458a93af" + "sha256": "560a5168f32bafd9e5173d49502e10aeb278fe64ed50262da29be208f4a66739" }, "pipfile-spec": 6, "requires": { @@ -355,8 +355,7 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", - "sha256:cebad02308a8c080ff0a3d3c9eec420a280dc4341cf8681381b444a176406a8e" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" ], "version": "==2.19" }, @@ -676,11 +675,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:47705be09c6e56e9e3ac1656e8f5ed70862a4657116dc472f5a56c1bdc5172b1", - "sha256:564702ace354e1059252755be79d082a70ae1851c86044ae1a96d0f5453280e9" + "sha256:19a6637a5da1bb7ea7948483ca9e2b9e15b213e687e7bf5ff8c1bfc91c185006", + "sha256:bb033b72cdd3a2b0a530bbdf2081f12fbea7d70baeaaebb5899723a45f424b8e" ], "index": "pypi", - "version": "==1.2.0" + "version": "==2.0.0" }, "flake8-bugbear": { "hashes": [ @@ -743,6 +742,14 @@ ], "version": "==2.9" }, + "importlib-metadata": { + "hashes": [ + "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", + "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" + ], + "markers": "python_version < '3.8'", + "version": "==1.5.0" + }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -853,6 +860,33 @@ ], "version": "==0.10.0" }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "markers": "python_version < '3.8'", + "version": "==1.4.1" + }, "unittest-xml-reporting": { "hashes": [ "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0", @@ -875,6 +909,13 @@ "sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd" ], "version": "==20.0.4" + }, + "zipp": { + "hashes": [ + "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2", + "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a" + ], + "version": "==3.0.0" } } } diff --git a/tox.ini b/tox.ini index d14819d57..b8293a3b6 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ max-line-length=120 docstring-convention=all import-order-style=pycharm application_import_names=bot,tests -exclude=.cache,.venv,constants.py +exclude=.cache,.venv,.git,constants.py ignore= B311,W503,E226,S311,T000 # Missing Docstrings @@ -15,5 +15,5 @@ ignore= # Docstring Content D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 # Type Annotations - TYP002,TYP003,TYP101,TYP102,TYP204,TYP206 -per-file-ignores=tests/*:D,TYP + ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 +per-file-ignores=tests/*:D,ANN -- cgit v1.2.3 From 7cfb72f0b25647da2b2b5e83daedc22ca5bdfe23 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 02:51:21 +0000 Subject: Implement basic Sentry SDK usage --- Pipfile | 1 + Pipfile.lock | 10 +++++++++- bot/__main__.py | 2 ++ bot/constants.py | 2 +- config-default.yml | 1 + 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Pipfile b/Pipfile index 11d5e7deb..400e64c18 100644 --- a/Pipfile +++ b/Pipfile @@ -18,6 +18,7 @@ deepdiff = "~=4.0" requests = "~=2.22" more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" +sentry-sdk = "~=0.14" [dev-packages] coverage = "~=4.5" diff --git a/Pipfile.lock b/Pipfile.lock index 633e62887..fa29bf995 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "560a5168f32bafd9e5173d49502e10aeb278fe64ed50262da29be208f4a66739" + "sha256": "c7706a61eb96c06d073898018ea2dbcf5bd3b15d007496e2d60120a65647f31e" }, "pipfile-spec": 6, "requires": { @@ -413,6 +413,14 @@ "index": "pypi", "version": "==2.23.0" }, + "sentry-sdk": { + "hashes": [ + "sha256:b06dd27391fd11fb32f84fe054e6a64736c469514a718a99fb5ce1dff95d6b28", + "sha256:e023da07cfbead3868e1e2ba994160517885a32dfd994fc455b118e37989479b" + ], + "index": "pypi", + "version": "==0.14.1" + }, "six": { "hashes": [ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", diff --git a/bot/__main__.py b/bot/__main__.py index 84bc7094b..4f51a08cc 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,10 +1,12 @@ import discord +import sentry_sdk from discord.ext.commands import when_mentioned_or from bot import patches from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE +sentry_sdk.init(BotConfig.sentry_dsn) bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), diff --git a/bot/constants.py b/bot/constants.py index fe8e57322..a4c65a1f8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -193,7 +193,7 @@ class Bot(metaclass=YAMLGetter): prefix: str token: str - + sentry_dsn: str class Filter(metaclass=YAMLGetter): section = "filter" diff --git a/config-default.yml b/config-default.yml index 3345e6f2a..2eaf8ee06 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,6 +1,7 @@ bot: prefix: "!" token: !ENV "BOT_TOKEN" + sentry_dsn: !ENV "BOT_SENTRY_DSN" cooldowns: # Per channel, per tag. -- cgit v1.2.3 From 23829807867c37c293f7ab4d998b86fbc548b980 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 03:04:58 +0000 Subject: Set BOT_SENTRY_DSN environment variable for tests --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0400ac4d2..874364a6f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,7 +30,7 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner + - script: BOT_API_KEY=foo BOT_SENTRY_DSN=blah BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner displayName: Run tests - script: coverage report -m && coverage xml -o coverage.xml -- cgit v1.2.3 From 03175ae8bfcb70b1b2c798267fdd23b48534b2b7 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 03:05:23 +0000 Subject: Attach extra information on a command error --- bot/cogs/error_handler.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 52893b2ee..021753081 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -15,6 +15,7 @@ from discord.ext.commands import ( UserInputError, ) from discord.ext.commands import Cog, Context +from sentry_sdk import configure_scope from bot.api import ResponseCodeError from bot.bot import Bot @@ -150,7 +151,18 @@ class ErrorHandler(Cog): log.error( f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}" ) - raise e + + with configure_scope() as scope: + scope.user = { + "username": ctx.author.username, + "id": ctx.author.id + } + + scope.set_tag("command", ctx.command.qualified_name) + scope.set_tag("message_id", ctx.message.id) + scope.set_extra("full_message", ctx.message.content) + + raise e def setup(bot: Bot) -> None: -- cgit v1.2.3 From 215483e390b7f5636cf1d46c96c216ef0d05cb03 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 11:14:03 +0000 Subject: Add basic sentry usage for command errors giving user information and command context. --- bot/__main__.py | 6 +++- bot/api.py | 74 ----------------------------------------------- bot/bot.py | 2 -- bot/cogs/error_handler.py | 13 ++++----- 4 files changed, 10 insertions(+), 85 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 4f51a08cc..51bba6191 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,12 +1,16 @@ import discord import sentry_sdk from discord.ext.commands import when_mentioned_or +from sentry_sdk.integrations.aiohttp import AioHttpIntegration from bot import patches from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE -sentry_sdk.init(BotConfig.sentry_dsn) +sentry_sdk.init( + dsn=BotConfig.sentry_dsn, + integrations=[AioHttpIntegration()] +) bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), diff --git a/bot/api.py b/bot/api.py index 56db99828..fb126b384 100644 --- a/bot/api.py +++ b/bot/api.py @@ -141,77 +141,3 @@ def loop_is_running() -> bool: except RuntimeError: return False return True - - -class APILoggingHandler(logging.StreamHandler): - """Site API logging handler.""" - - def __init__(self, client: APIClient): - logging.StreamHandler.__init__(self) - self.client = client - - # internal batch of shipoff tasks that must not be scheduled - # on the event loop yet - scheduled when the event loop is ready. - self.queue = [] - - async def ship_off(self, payload: dict) -> None: - """Ship log payload to the logging API.""" - try: - await self.client.post('logs', json=payload) - except ResponseCodeError as err: - log.warning( - "Cannot send logging record to the site, got code %d.", - err.response.status, - extra={'via_handler': True} - ) - except Exception as err: - log.warning( - "Cannot send logging record to the site: %r", - err, - extra={'via_handler': True} - ) - - def emit(self, record: logging.LogRecord) -> None: - """ - Determine if a log record should be shipped to the logging API. - - If the asyncio event loop is not yet running, log records will instead be put in a queue - which will be consumed once the event loop is running. - - The following two conditions are set: - 1. Do not log anything below DEBUG (only applies to the monkeypatched `TRACE` level) - 2. Ignore log records originating from this logging handler itself to prevent infinite recursion - """ - if ( - record.levelno >= logging.DEBUG - and not record.__dict__.get('via_handler') - ): - payload = { - 'application': 'bot', - 'logger_name': record.name, - 'level': record.levelname.lower(), - 'module': record.module, - 'line': record.lineno, - 'message': self.format(record) - } - - task = self.ship_off(payload) - if not loop_is_running(): - self.queue.append(task) - else: - asyncio.create_task(task) - self.schedule_queued_tasks() - - def schedule_queued_tasks(self) -> None: - """Consume the queue and schedule the logging of each queued record.""" - for task in self.queue: - asyncio.create_task(task) - - if self.queue: - log.debug( - "Scheduled %d pending logging tasks.", - len(self.queue), - extra={'via_handler': True} - ) - - self.queue.clear() diff --git a/bot/bot.py b/bot/bot.py index 8f808272f..cecee7b68 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -27,8 +27,6 @@ class Bot(commands.Bot): self.http_session: Optional[aiohttp.ClientSession] = None self.api_client = api.APIClient(loop=self.loop, connector=self.connector) - log.addHandler(api.APILoggingHandler(self.api_client)) - def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" super().add_cog(cog) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 021753081..e76f1a086 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -15,7 +15,7 @@ from discord.ext.commands import ( UserInputError, ) from discord.ext.commands import Cog, Context -from sentry_sdk import configure_scope +from sentry_sdk import capture_exception, configure_scope from bot.api import ResponseCodeError from bot.bot import Bot @@ -148,21 +148,18 @@ class ErrorHandler(Cog): f"Sorry, an unexpected error occurred. Please let us know!\n\n" f"```{e.__class__.__name__}: {e}```" ) - log.error( - f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}" - ) with configure_scope() as scope: scope.user = { - "username": ctx.author.username, - "id": ctx.author.id + "id": ctx.author.id, + "username": str(ctx.author) } scope.set_tag("command", ctx.command.qualified_name) scope.set_tag("message_id", ctx.message.id) scope.set_extra("full_message", ctx.message.content) - - raise e + + capture_exception(e) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 7b619a2dd17e00464d93b56717391fe53e0fe6bb Mon Sep 17 00:00:00 2001 From: Deniz Date: Fri, 21 Feb 2020 17:48:10 +0100 Subject: Use the code provided by sco1 to fix the checks failing. --- bot/cogs/information.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index c8b5eb5ad..fd49e2828 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,6 +3,7 @@ import logging import pprint import textwrap from collections import Counter, defaultdict +from string import Template from typing import Any, Mapping, Optional, Union from discord import Colour, Embed, Member, Message, Role, Status, utils @@ -109,10 +110,14 @@ class Information(Cog): # How many of each user status? statuses = Counter(member.status for member in ctx.guild.members) + embed = Embed(colour=Colour.blurple()) - embed = Embed( - colour=Colour.blurple(), - description=textwrap.dedent(f""" + # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the + # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts + # after the dedent is made. + embed.description = Template( + textwrap.dedent(f""" **Server information** Created: {created} Voice region: {region} @@ -121,7 +126,7 @@ class Information(Cog): **Counts** Members: {member_count:,} Roles: {roles} - {channel_counts} + $channel_counts **Members** {constants.Emojis.status_online} {statuses[Status.online]:,} @@ -129,7 +134,7 @@ class Information(Cog): {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} {constants.Emojis.status_offline} {statuses[Status.offline]:,} """) - ) + ).substitute({"channel_counts": channel_counts}) embed.set_thumbnail(url=ctx.guild.icon_url) await ctx.send(embed=embed) -- cgit v1.2.3 From c46498ad20c463d72e6d5da05852371f9ab20e6c Mon Sep 17 00:00:00 2001 From: Deniz Date: Fri, 21 Feb 2020 17:57:36 +0100 Subject: Remove the space that makes the test fail --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index fd49e2828..13c8aabaa 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -106,7 +106,7 @@ class Information(Cog): # How many of each type of channel? channels = Counter(c.type for c in ctx.guild.channels) - channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]} \n" for ch in channels)).strip() + channel_counts = "".join(sorted(f"{str(ch).title()} channels: {channels[ch]}\n" for ch in channels)).strip() # How many of each user status? statuses = Counter(member.status for member in ctx.guild.members) -- 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(-) 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 5c0819890259e446273707ec2dbb4b8312c9e19f Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 17:36:05 +0000 Subject: Remove AIOHTTP integration from Sentry since it is intended for AIOHTTP servers. --- bot/__main__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 51bba6191..2ed078903 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,15 +1,13 @@ import discord import sentry_sdk from discord.ext.commands import when_mentioned_or -from sentry_sdk.integrations.aiohttp import AioHttpIntegration from bot import patches from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE sentry_sdk.init( - dsn=BotConfig.sentry_dsn, - integrations=[AioHttpIntegration()] + dsn=BotConfig.sentry_dsn ) bot = Bot( -- cgit v1.2.3 From 24ddab52ac1db77f4dd061d22ab4d622e60da237 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 17:51:09 +0000 Subject: Use log.error instead of capture_exception --- bot/cogs/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index e76f1a086..8261633ee 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -15,7 +15,7 @@ from discord.ext.commands import ( UserInputError, ) from discord.ext.commands import Cog, Context -from sentry_sdk import capture_exception, configure_scope +from sentry_sdk import configure_scope from bot.api import ResponseCodeError from bot.bot import Bot @@ -159,7 +159,7 @@ class ErrorHandler(Cog): scope.set_tag("message_id", ctx.message.id) scope.set_extra("full_message", ctx.message.content) - capture_exception(e) + log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) def setup(bot: Bot) -> None: -- cgit v1.2.3 From cc623e96965d511e7408590d36b7017ea9d6b495 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 18:01:12 +0000 Subject: Use push_scope instead of configure_scope --- bot/cogs/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 8261633ee..589448b9a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -15,7 +15,7 @@ from discord.ext.commands import ( UserInputError, ) from discord.ext.commands import Cog, Context -from sentry_sdk import configure_scope +from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot @@ -149,7 +149,7 @@ class ErrorHandler(Cog): f"```{e.__class__.__name__}: {e}```" ) - with configure_scope() as scope: + with push_scope() as scope: scope.user = { "id": ctx.author.id, "username": str(ctx.author) -- cgit v1.2.3 From 969f1eea95a9bd44de65bd08b8a551538b7b6581 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 18:01:35 +0000 Subject: Attach jump_to url to the event --- bot/cogs/error_handler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 589448b9a..0abb7e521 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -157,8 +157,16 @@ class ErrorHandler(Cog): scope.set_tag("command", ctx.command.qualified_name) scope.set_tag("message_id", ctx.message.id) + scope.set_tag("channel_id", ctx.channel.id) + scope.set_extra("full_message", ctx.message.content) + if ctx.guild is not None: + scope.set_extra( + "jump_to", + f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" + ) + log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) -- cgit v1.2.3 From e3fab4567031cb0a3087b3335cd30f87e0027301 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 21 Feb 2020 12:11:12 -0800 Subject: Bot: send empty cache warning to a webhook This is more visible than it would be if it was only logged. * Add a webhook for the dev-log channel to constants --- bot/bot.py | 13 ++++++++++--- bot/constants.py | 1 + config-default.yml | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index e5b9717db..c818e79fb 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -68,9 +68,16 @@ class Bot(commands.Bot): return if not guild.roles or not guild.members or not guild.channels: - log.warning( - "Guild available event was dispatched but the cache appears to still be empty!" - ) + msg = "Guild available event was dispatched but the cache appears to still be empty!" + log.warning(msg) + + try: + webhook = await self.fetch_webhook(constants.Webhooks.dev_log) + except discord.HTTPException as e: + log.error(f"Failed to fetch webhook to send empty cache warning: status {e.status}") + else: + await webhook.send(f"<@&{constants.Roles.admin}> {msg}") + return self._guild_available.set() diff --git a/bot/constants.py b/bot/constants.py index 81ce3e903..9856854d7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -406,6 +406,7 @@ class Webhooks(metaclass=YAMLGetter): big_brother: int reddit: int duck_pond: int + dev_log: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 0ebdc4080..6808925c2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -179,6 +179,7 @@ guild: big_brother: 569133704568373283 reddit: 635408384794951680 duck_pond: 637821475327311927 + dev_log: 680501655111729222 filter: -- cgit v1.2.3 From 282a11ef2f52cf91464c1de056ac56b0a3a4c436 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 22:48:20 +0000 Subject: Enable logging warnings to Sentry --- bot/__init__.py | 4 ++-- bot/__main__.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index 923ef517b..90ab3c348 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -40,6 +40,6 @@ root_log.setLevel(TRACE_LEVEL if DEBUG_MODE else logging.INFO) root_log.addHandler(stream_handler) root_log.addHandler(file_handler) -logging.getLogger("discord").setLevel(logging.ERROR) -logging.getLogger("websockets").setLevel(logging.ERROR) +logging.getLogger("discord").setLevel(logging.WARNING) +logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger(__name__).setLevel(TRACE_LEVEL) diff --git a/bot/__main__.py b/bot/__main__.py index 2ed078903..490163739 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,13 +1,22 @@ +import logging + import discord import sentry_sdk from discord.ext.commands import when_mentioned_or +from sentry_sdk.integrations.logging import LoggingIntegration from bot import patches from bot.bot import Bot from bot.constants import Bot as BotConfig, DEBUG_MODE +sentry_logging = LoggingIntegration( + level=logging.TRACE, + event_level=logging.WARNING +) + sentry_sdk.init( - dsn=BotConfig.sentry_dsn + dsn=BotConfig.sentry_dsn, + integrations=[sentry_logging] ) bot = Bot( -- cgit v1.2.3 From 22c2d98137f315fc8f558c6b41648c003a0ac328 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 21 Feb 2020 23:03:42 +0000 Subject: Change defcon log levels from warning to info --- bot/cogs/defcon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 3e7350fcc..a0d8fedd5 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -76,12 +76,12 @@ class Defcon(Cog): if data["enabled"]: self.enabled = True self.days = timedelta(days=data["days"]) - log.warning(f"DEFCON enabled: {self.days.days} days") + log.info(f"DEFCON enabled: {self.days.days} days") else: self.enabled = False self.days = timedelta(days=0) - log.warning(f"DEFCON disabled") + log.info(f"DEFCON disabled") await self.update_channel_topic() -- cgit v1.2.3 From b554470d35176246b6a18190230b81d592389056 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 23 Feb 2020 16:12:48 +1000 Subject: Suppress NotFound on react clear, tidy imports. --- bot/pagination.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index e82763912..d6bfe6205 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,8 +1,9 @@ import asyncio import logging -from typing import Iterable, List, Optional, Tuple +import typing as t +from contextlib import suppress -from discord import Embed, Member, Message, Reaction +import discord from discord.abc import User from discord.ext.commands import Context, Paginator @@ -89,12 +90,12 @@ class LinePaginator(Paginator): @classmethod async def paginate( cls, - lines: Iterable[str], + lines: t.List[str], ctx: Context, - embed: Embed, + embed: discord.Embed, prefix: str = "", suffix: str = "", - max_lines: Optional[int] = None, + max_lines: t.Optional[int] = None, max_size: int = 500, empty: bool = True, restrict_to_user: User = None, @@ -102,7 +103,7 @@ class LinePaginator(Paginator): footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False - ) -> Optional[Message]: + ) -> t.Optional[discord.Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -114,11 +115,11 @@ class LinePaginator(Paginator): Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). Example: - >>> embed = Embed() + >>> embed = discord.Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await LinePaginator.paginate((line for line in lines), ctx, embed) + >>> await LinePaginator.paginate([line for line in lines], ctx, embed) """ - def event_check(reaction_: Reaction, user_: Member) -> bool: + def event_check(reaction_: discord.Reaction, user_: discord.Member) -> bool: """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( # Pagination is not restricted @@ -281,8 +282,9 @@ class LinePaginator(Paginator): await message.edit(embed=embed) - log.debug("Ending pagination and removing all reactions...") - await message.clear_reactions() + log.debug("Ending pagination and clearing reactions.") + with suppress(discord.NotFound): + await message.clear_reactions() class ImagePaginator(Paginator): @@ -316,13 +318,13 @@ class ImagePaginator(Paginator): @classmethod async def paginate( cls, - pages: List[Tuple[str, str]], - ctx: Context, embed: Embed, + pages: t.List[t.Tuple[str, str]], + ctx: Context, embed: discord.Embed, prefix: str = "", suffix: str = "", timeout: int = 300, exception_on_empty_embed: bool = False - ) -> Optional[Message]: + ) -> t.Optional[discord.Message]: """ Use a paginator and set of reactions to provide pagination over a set of title/image pairs. @@ -334,11 +336,11 @@ class ImagePaginator(Paginator): Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). Example: - >>> embed = Embed() + >>> embed = discord.Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) >>> await ImagePaginator.paginate(pages, ctx, embed) """ - def check_event(reaction_: Reaction, member: Member) -> bool: + def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool: """Checks each reaction added, if it matches our conditions pass the wait_for.""" return all(( # Reaction is on the same message sent @@ -445,5 +447,6 @@ class ImagePaginator(Paginator): await message.edit(embed=embed) - log.debug("Ending pagination and removing all reactions...") - await message.clear_reactions() + log.debug("Ending pagination and clearing reactions.") + with suppress(discord.NotFound): + await message.clear_reactions() -- cgit v1.2.3 From a18f7ede045f10c6a6a06cae36b9450f869cf5ca Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 23 Feb 2020 16:13:10 +1000 Subject: Define `_count` in `__init__`. --- bot/pagination.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/pagination.py b/bot/pagination.py index d6bfe6205..b6525c2d0 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -301,6 +301,7 @@ class ImagePaginator(Paginator): self._current_page = [prefix] self.images = [] self._pages = [] + self._count = 0 def add_line(self, line: str = '', *, empty: bool = False) -> None: """Adds a line to each page.""" -- cgit v1.2.3 From e8ad8bb3cf735e9fef09413b8b118b2eff601797 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 23 Feb 2020 19:42:00 +1000 Subject: Don't set project log level so it uses root level. --- bot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__init__.py b/bot/__init__.py index 90ab3c348..f7a410706 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -42,4 +42,4 @@ root_log.addHandler(file_handler) logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) -logging.getLogger(__name__).setLevel(TRACE_LEVEL) +logging.getLogger(__name__) -- cgit v1.2.3 From a809754a1ad7e920180b8ff841282b7337ec0e4f Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 23 Feb 2020 20:32:47 +1000 Subject: Don't log exception traceback on Forbidden for welcomes. --- bot/cogs/verification.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 988e0d49a..f13ccd728 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,8 @@ import logging +from contextlib import suppress from datetime import datetime -from discord import Colour, Message, NotFound, Object +from discord import Colour, Forbidden, Message, NotFound, Object from discord.ext import tasks from discord.ext.commands import Cog, Context, command @@ -127,17 +128,13 @@ class Verification(Cog): await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules") try: await ctx.author.send(WELCOME_MESSAGE) - except Exception: - # Catch the exception, in case they have DMs off or something - log.exception(f"Unable to send welcome message to user {ctx.author}.") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - try: - self.mod_log.ignore(Event.message_delete, ctx.message.id) - await ctx.message.delete() - except NotFound: - log.trace("No message found, it must have been deleted by another bot.") + except Forbidden: + log.info(f"Sending welcome message failed for {ctx.author}.") + finally: + log.trace(f"Deleting accept message by {ctx.author}.") + with suppress(NotFound): + self.mod_log.ignore(Event.message_delete, ctx.message.id) + await ctx.message.delete() @command(name='subscribe') @in_channel(Channels.bot) -- cgit v1.2.3 From cab9d3a6b2e038bbb0fe080cb0f024ad39e42e5d Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 23 Feb 2020 21:39:06 +1000 Subject: Check reminder user and channel before send and schedule. --- bot/cogs/reminders.py | 55 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 45bf9a8f4..105a08465 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -2,12 +2,12 @@ import asyncio import logging import random import textwrap +import typing as t from datetime import datetime, timedelta from operator import itemgetter -from typing import Optional +import discord from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Message from discord.ext.commands import Cog, Context, group from bot.bot import Bot @@ -45,6 +45,10 @@ class Reminders(Scheduler, Cog): loop = asyncio.get_event_loop() for reminder in response: + is_valid, *_ = self.ensure_valid_reminder(reminder) + if not is_valid: + continue + remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) # If the reminder is already overdue ... @@ -55,11 +59,26 @@ class Reminders(Scheduler, Cog): else: self.schedule_task(loop, reminder["id"], reminder) + def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: + """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" + user = self.bot.get_user(reminder['author']) + channel = self.bot.get_channel(reminder['channel_id']) + is_valid = True + if not user or not channel: + is_valid = False + log.info( + f"Reminder {reminder['id']} invalid: " + f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." + ) + asyncio.create_task(self._delete_reminder(reminder['id'])) + + return is_valid, user, channel + @staticmethod async def _send_confirmation(ctx: Context, on_success: str) -> None: """Send an embed confirming the reminder change was made successfully.""" - embed = Embed() - embed.colour = Colour.green() + embed = discord.Embed() + embed.colour = discord.Colour.green() embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success await ctx.send(embed=embed) @@ -95,11 +114,13 @@ class Reminders(Scheduler, Cog): async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" - channel = self.bot.get_channel(reminder["channel_id"]) - user = self.bot.get_user(reminder["author"]) + is_valid, user, channel = self.ensure_valid_reminder(reminder) + if not is_valid: + await self._delete_reminder(reminder["id"]) + return - embed = Embed() - embed.colour = Colour.blurple() + embed = discord.Embed() + embed.colour = discord.Colour.blurple() embed.set_author( icon_url=Icons.remind_blurple, name="It has arrived!" @@ -111,7 +132,7 @@ class Reminders(Scheduler, Cog): embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" if late: - embed.colour = Colour.red() + embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" @@ -129,20 +150,20 @@ class Reminders(Scheduler, Cog): await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> Optional[Message]: + async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]: """ Set yourself a simple reminder. Expiration is parsed per: http://strftime.org/ """ - embed = Embed() + embed = discord.Embed() # If the user is not staff, we need to verify whether or not to make a reminder at all. if without_role_check(ctx, *STAFF_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - embed.colour = Colour.red() + embed.colour = discord.Colour.red() embed.title = random.choice(NEGATIVE_REPLIES) embed.description = "Sorry, you can't do that here!" @@ -159,7 +180,7 @@ class Reminders(Scheduler, Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - embed.colour = Colour.red() + embed.colour = discord.Colour.red() embed.title = random.choice(NEGATIVE_REPLIES) embed.description = "You have too many active reminders!" @@ -189,7 +210,7 @@ class Reminders(Scheduler, Cog): self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> Optional[Message]: + async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. data = await self.bot.api_client.get( @@ -222,8 +243,8 @@ class Reminders(Scheduler, Cog): lines.append(text) - embed = Embed() - embed.colour = Colour.blurple() + embed = discord.Embed() + embed.colour = discord.Colour.blurple() embed.title = f"Reminders for {ctx.author}" # Remind the user that they have no reminders :^) @@ -232,7 +253,7 @@ class Reminders(Scheduler, Cog): return await ctx.send(embed=embed) # Construct the embed and paginate it. - embed.colour = Colour.blurple() + embed.colour = discord.Colour.blurple() await LinePaginator.paginate( lines, -- cgit v1.2.3 From 0585a5495ea1ee3dea2a256795005942025f6dc5 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Sun, 23 Feb 2020 21:51:58 +1000 Subject: Remove call to delete reminder, as ensure method already does it. --- bot/cogs/reminders.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 105a08465..ef46f4f3e 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -116,7 +116,6 @@ class Reminders(Scheduler, Cog): """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: - await self._delete_reminder(reminder["id"]) return embed = discord.Embed() -- cgit v1.2.3 From 470adee6e9069782189e803752fa2d9ee08465e4 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 24 Feb 2020 01:15:52 +1000 Subject: Reduce log level of tag cooldown notice. --- bot/cogs/tags.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 54a51921c..b6360dfae 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -116,8 +116,10 @@ class Tags(Cog): if _command_on_cooldown(tag_name): time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"]) - log.warning(f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " - f"Cooldown ends in {time_left:.1f} seconds.") + log.info( + f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " + f"Cooldown ends in {time_left:.1f} seconds." + ) return await self._get_tags() -- cgit v1.2.3 From 12fdfde0ce1cbc4df7c7d9184acb7cda4f1d92db Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 24 Feb 2020 01:49:44 +1000 Subject: Change verification post log level to info, tidy code. --- bot/cogs/verification.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index f13ccd728..582237374 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -93,19 +93,21 @@ class Verification(Cog): ping_everyone=Filter.ping_everyone, ) - ctx = await self.bot.get_context(message) # type: Context - + ctx: Context = await self.bot.get_context(message) if ctx.command is not None and ctx.command.name == "accept": - return # They used the accept command + return - for role in ctx.author.roles: - if role.id == Roles.verified: - log.warning(f"{ctx.author} posted '{ctx.message.content}' " - "in the verification channel, but is already verified.") - return # They're already verified + if any(r.id == Roles.verified for r in ctx.author.roles): + log.info( + f"{ctx.author} posted '{ctx.message.content}' " + "in the verification channel, but is already verified." + ) + return - log.debug(f"{ctx.author} posted '{ctx.message.content}' in the verification " - "channel. We are providing instructions how to verify.") + log.debug( + f"{ctx.author} posted '{ctx.message.content}' in the verification " + "channel. We are providing instructions how to verify." + ) await ctx.send( f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " f"and gain access to the rest of the server.", @@ -113,11 +115,8 @@ class Verification(Cog): ) log.trace(f"Deleting the message posted by {ctx.author}") - - try: + with suppress(NotFound): await ctx.message.delete() - except NotFound: - log.trace("No message found, it must have been deleted by another bot.") @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(Roles.verified) -- cgit v1.2.3 From 5c8a4954d9d22e444cfed6f35057d85185100043 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 23 Feb 2020 18:14:26 +0100 Subject: Add Sentdex server to whitelist --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 2eaf8ee06..ba6ea2742 100644 --- a/config-default.yml +++ b/config-default.yml @@ -217,6 +217,7 @@ filter: - 438622377094414346 # Pyglet - 524691714909274162 # Panda3D - 336642139381301249 # discord.py + - 405403391410438165 # Sentdex domain_blacklist: - pornhub.com -- cgit v1.2.3 From f91c32fed74bd5daebb8438c79f4d2d9efbc1459 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 09:59:28 -0800 Subject: Reminders: don't cancel task if reminder is invalid when rescheduling If a reminder is invalid, it won't get rescheduled. Therefore, there wouldn't exist a task to cancel and it'd raise a warning. Fixes BOT-1C --- bot/cogs/reminders.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ef46f4f3e..f3e516158 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -45,7 +45,7 @@ class Reminders(Scheduler, Cog): loop = asyncio.get_event_loop() for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder) + is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) if not is_valid: continue @@ -59,7 +59,11 @@ class Reminders(Scheduler, Cog): else: self.schedule_task(loop, reminder["id"], reminder) - def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: + def ensure_valid_reminder( + self, + reminder: dict, + cancel_task: bool = True + ) -> t.Tuple[bool, discord.User, discord.TextChannel]: """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" user = self.bot.get_user(reminder['author']) channel = self.bot.get_channel(reminder['channel_id']) @@ -70,7 +74,7 @@ class Reminders(Scheduler, Cog): f"Reminder {reminder['id']} invalid: " f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self._delete_reminder(reminder['id'])) + asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) return is_valid, user, channel @@ -98,12 +102,13 @@ class Reminders(Scheduler, Cog): # Now we can begone with it from our schedule list. self.cancel_task(reminder_id) - async def _delete_reminder(self, reminder_id: str) -> None: + async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - # Now we can remove it from the schedule list - self.cancel_task(reminder_id) + if cancel_task: + # Now we can remove it from the schedule list + self.cancel_task(reminder_id) async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" -- cgit v1.2.3 From f1c987978d7c66a7886c19a40a00fa9d2b8c7d0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 15:35:59 -0800 Subject: Sync: code style refactoring * Convert diff namedtuple to dict outside the dict comprehension * Define long condition as a boolean instead of in the if statement * Pass role and user dicts to aiohttp normally instead of unpacking --- bot/cogs/sync/cog.py | 6 ++++-- bot/cogs/sync/syncers.py | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ee3cccbfa..5708be3f4 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -65,12 +65,14 @@ class Sync(Cog): @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" - if ( + was_updated = ( before.name != after.name or before.colour != after.colour or before.permissions != after.permissions or before.position != after.position - ): + ) + + if was_updated: await self.bot.api_client.put( f'bot/roles/{after.id}', json={ diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 43a8f2b62..6715ad6fb 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -192,7 +192,8 @@ class Syncer(abc.ABC): author = ctx.author diff = await self._get_diff(guild) - totals = {k: len(v) for k, v in diff._asdict().items() if v is not None} + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + totals = {k: len(v) for k, v in diff_dict.items() if v is not None} diff_size = sum(totals.values()) confirmed, message = await self._get_confirmation_result(diff_size, author, message) @@ -261,11 +262,11 @@ class RoleSyncer(Syncer): """Synchronise the database with the role cache of `guild`.""" log.trace("Syncing created roles...") for role in diff.created: - await self.bot.api_client.post('bot/roles', json={**role._asdict()}) + await self.bot.api_client.post('bot/roles', json=role._asdict()) log.trace("Syncing updated roles...") for role in diff.updated: - await self.bot.api_client.put(f'bot/roles/{role.id}', json={**role._asdict()}) + await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) log.trace("Syncing deleted roles...") for role in diff.deleted: @@ -334,8 +335,8 @@ class UserSyncer(Syncer): """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") for user in diff.created: - await self.bot.api_client.post('bot/users', json={**user._asdict()}) + await self.bot.api_client.post('bot/users', json=user._asdict()) log.trace("Syncing updated users...") for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json={**user._asdict()}) + await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) -- cgit v1.2.3