From 45580f7e98956309681820d23df3d70eb8312f4d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 16:34:48 -0700 Subject: Silence: revoke permissions to add reactions No longer assume default values for the overwrites which are modified. Save and restore previous values `add_reactions` and `send_messages` via redis. When unsilencing, check if a channel is silenced via the redis cache rather than the channel's current overwrites to ensure the task is cancelled even if overwrites were manually edited. --- bot/cogs/moderation/silence.py | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f8a6592bc..0f3c98306 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,4 +1,5 @@ import asyncio +import json import logging from contextlib import suppress from typing import Optional @@ -10,6 +11,7 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -57,10 +59,13 @@ class SilenceNotifier(tasks.Loop): class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" + # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. + # Overwrites are stored as JSON. + muted_channel_perms = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() @@ -118,12 +123,17 @@ class Silence(commands.Cog): `duration` is only used for logging; if None is passed `persistent` should be True to not log None. Return `True` if channel permissions were changed, `False` otherwise. """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: + overwrite = channel.overwrites_for(self._verified_role) + prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + + if all(val is False for val in prev_overwrites.values()): log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) - self.muted_channels.add(channel) + + overwrite.update(send_messages=False, add_reactions=False) + await channel.set_permissions(self._verified_role, overwrite=overwrite) + await self.muted_channel_perms.set(channel.id, json.dumps(prev_overwrites)) + if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) @@ -140,22 +150,28 @@ class Silence(commands.Cog): if it is unsilence it and remove it from the notifier. Return `True` if channel permissions were changed, `False` otherwise. """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) + prev_overwrites = await self.muted_channel_perms.get(channel.id) + if prev_overwrites is not None: + overwrite = channel.overwrites_for(self._verified_role) + overwrite.update(**json.loads(prev_overwrites)) + + await channel.set_permissions(self._verified_role, overwrite=overwrite) log.info(f"Unsilenced channel #{channel} ({channel.id}).") + self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) - self.muted_channels.discard(channel) + await self.muted_channel_perms.delete(channel.id) + return True + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False def cog_unload(self) -> None: """Send alert with silenced channels and cancel scheduled tasks on unload.""" self.scheduler.cancel_all() - if self.muted_channels: - channels_string = ''.join(channel.mention for channel in self.muted_channels) + if self.muted_channel_perms: + channels_string = ''.join(channel.mention for channel in self.muted_channel_perms) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" asyncio.create_task(self._mod_alerts_channel.send(message)) -- cgit v1.2.3 From 1ff8559dda1caa9c8479da5d371ff96aa4797e7c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 18:45:32 -0700 Subject: Silence: abort silence if there's already a scheduled task Overwrites can be edited during a silence, which can result in the overwrites check failing. Checking the scheduler too ensures that a duplicate silence won't be scheduled. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0f3c98306..0f64301c4 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -126,7 +126,7 @@ class Silence(commands.Cog): overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) - if all(val is False for val in prev_overwrites.values()): + if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False -- cgit v1.2.3 From 68f2fbcb5afd474bc06c50267655177cf3617c5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 19:18:24 -0700 Subject: Silence: notify admins if previous overwrites were not cached Admins will have to manually check the default values used and adjust them if they aren't the desired values for that particular channel. --- bot/cogs/moderation/silence.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0f64301c4..ea2f51574 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -146,26 +146,39 @@ class Silence(commands.Cog): """ Unsilence `channel`. - Check if `channel` is silenced through a `PermissionOverwrite`, - if it is unsilence it and remove it from the notifier. + If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence + it, cancel the task, and remove it from the notifier. Notify admins if it has a task but + not cached overwrites. + Return `True` if channel permissions were changed, `False` otherwise. """ prev_overwrites = await self.muted_channel_perms.get(channel.id) - if prev_overwrites is not None: - overwrite = channel.overwrites_for(self._verified_role) + if channel.id not in self.scheduler and prev_overwrites is None: + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + overwrite = channel.overwrites_for(self._verified_role) + if prev_overwrites is None: + log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") + overwrite.update(send_messages=None, add_reactions=None) + else: overwrite.update(**json.loads(prev_overwrites)) - await channel.set_permissions(self._verified_role, overwrite=overwrite) - log.info(f"Unsilenced channel #{channel} ({channel.id}).") + await channel.set_permissions(self._verified_role, overwrite=overwrite) + log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.scheduler.cancel(channel.id) - self.notifier.remove_channel(channel) - await self.muted_channel_perms.delete(channel.id) + self.scheduler.cancel(channel.id) + self.notifier.remove_channel(channel) + await self.muted_channel_perms.delete(channel.id) - return True + if prev_overwrites is None: + await self._mod_alerts_channel.send( + f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " + f"overwrites for {self._verified_role.mention} are at their desired values." + ) - log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") - return False + return True def cog_unload(self) -> None: """Send alert with silenced channels and cancel scheduled tasks on unload.""" -- cgit v1.2.3 From c5617ec5d9ed9f0a76b2b8eb89ed28001a0542f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 20:21:13 -0700 Subject: Silence: add separate unsilence error for manually-silenced channels It was confusing to reject a silence and an unsilence when overwrites were manually set to False. That's because it's contradictory to show an error stating it's already silence but then reject an unsilence with an error stating the channel isn't silenced. --- bot/cogs/moderation/silence.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index ea2f51574..9863730f4 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -110,8 +110,16 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + if not await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + overwrite = ctx.channel.overwrites_for(self._verified_role) + if overwrite.send_messages is False and overwrite.add_reactions is False: + await ctx.send( + f"{Emojis.cross_mark} current channel was not unsilenced because the current " + f"overwrites were set manually. Please edit them manually to unsilence." + ) + else: + await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") else: await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 72050ffa47b52daf2fd12ff414b68224e0678fe5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 21:03:10 -0700 Subject: Silence: persist silenced channels Can be used to support rescheduling. --- bot/cogs/moderation/silence.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 9863730f4..c43194511 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -2,6 +2,7 @@ import asyncio import json import logging from contextlib import suppress +from datetime import datetime, timedelta from typing import Optional from discord import TextChannel @@ -63,6 +64,10 @@ class Silence(commands.Cog): # Overwrites are stored as JSON. muted_channel_perms = RedisCache() + # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. + # A timestamp equal to -1 means it's indefinite. + muted_channel_times = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -90,16 +95,21 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") + if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return + if duration is None: await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + await self.muted_channel_times.set(ctx.channel.id, -1) return await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + unsilence_time = (datetime.utcnow() + timedelta(minutes=duration)) + await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -178,6 +188,7 @@ class Silence(commands.Cog): self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) await self.muted_channel_perms.delete(channel.id) + await self.muted_channel_times.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( -- cgit v1.2.3 From 8f4ef1fc0f591f294f46a850d7047fe51a391f1f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 23:11:49 -0700 Subject: Silence: reschedule silences on startup Remove the moderator notification when unloading the cog because. Its purpose was to remind to manually unsilence channels. However, this purpose is now obsolete due to automatic rescheduling. The notification was buggy anyway due to a race condition with the bot shutting down, and that'd be further complicated by having to asynchronously retrieve channels from the redis cache too. Fixes #1053 --- bot/cogs/moderation/silence.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c43194511..02d8de29e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -78,11 +78,14 @@ class Silence(commands.Cog): async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) self.notifier = SilenceNotifier(self._mod_log_channel) + await self._reschedule() + self._get_instance_vars_event.set() @commands.command(aliases=("hush",)) @@ -120,18 +123,21 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + await self._unsilence_wrapper(ctx.channel) - if not await self._unsilence(ctx.channel): - overwrite = ctx.channel.overwrites_for(self._verified_role) + async def _unsilence_wrapper(self, channel: TextChannel) -> None: + """Unsilence `channel` and send a success/failure message.""" + if not await self._unsilence(channel): + overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False and overwrite.add_reactions is False: - await ctx.send( + await channel.send( f"{Emojis.cross_mark} current channel was not unsilenced because the current " f"overwrites were set manually. Please edit them manually to unsilence." ) else: - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + await channel.send(f"{Emojis.cross_mark} current channel was not silenced.") else: - await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") + await channel.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ @@ -199,13 +205,29 @@ class Silence(commands.Cog): return True + async def _reschedule(self) -> None: + """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" + for channel_id, timestamp in await self.muted_channel_times.items(): + channel = self.bot.get_channel(channel_id) + if channel is None: + log.info(f"Can't reschedule silence for {channel_id}: channel not found.") + continue + + if timestamp == -1: + log.info(f"Adding permanent silence for #{channel} ({channel.id}) to the notifier.") + self.notifier.add_channel(channel) + continue + + dt = datetime.utcfromtimestamp(timestamp) + if dt <= datetime.utcnow(): + await self._unsilence_wrapper(channel) + else: + log.info(f"Rescheduling silence for #{channel} ({channel.id}).") + self.scheduler.schedule_at(dt, channel_id, self._unsilence_wrapper(channel)) + def cog_unload(self) -> None: - """Send alert with silenced channels and cancel scheduled tasks on unload.""" + """Cancel scheduled tasks.""" self.scheduler.cancel_all() - if self.muted_channel_perms: - channels_string = ''.join(channel.mention for channel in self.muted_channel_perms) - message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" - asyncio.create_task(self._mod_alerts_channel.send(message)) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From bebe09b74b24e140581cb317f300d2bbad8999df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:06:47 -0700 Subject: Silence: use aware datetimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datetime.timestamp()` assumes naïve `datetime`s are in local time, so getting POSIX timestamps in UTC isn't easy for naïve ones. Technically, the timestamp's timezone doesn't matter if all code is on the same page and parsing it with the same timezone. Keeping it in the local timezone would be okay then, but I feel safer locking it to UTC explicitly. --- bot/cogs/moderation/silence.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 02d8de29e..adf469661 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -2,7 +2,7 @@ import asyncio import json import logging from contextlib import suppress -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from discord import TextChannel @@ -111,7 +111,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = (datetime.utcnow() + timedelta(minutes=duration)) + unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) @@ -218,12 +218,13 @@ class Silence(commands.Cog): self.notifier.add_channel(channel) continue - dt = datetime.utcfromtimestamp(timestamp) - if dt <= datetime.utcnow(): + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() + if delta <= 0: await self._unsilence_wrapper(channel) else: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") - self.scheduler.schedule_at(dt, channel_id, self._unsilence_wrapper(channel)) + self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: """Cancel scheduled tasks.""" -- cgit v1.2.3 From 235719b45899f3fb832b86dc578ca4430eeff259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:41:15 -0700 Subject: Silence: remove event and await _get_instance_vars_task directly The event is redundant because the task can be awaited directly to block until it's complete. If the task is already done, the await will instantly finish. --- bot/cogs/moderation/silence.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index adf469661..4910c7009 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,4 +1,3 @@ -import asyncio import json import logging from contextlib import suppress @@ -73,7 +72,6 @@ class Silence(commands.Cog): self.scheduler = Scheduler(self.__class__.__name__) self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) - self._get_instance_vars_event = asyncio.Event() async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -86,8 +84,6 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self._mod_log_channel) await self._reschedule() - self._get_instance_vars_event.set() - @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ @@ -96,7 +92,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ - await self._get_instance_vars_event.wait() + await self._get_instance_vars_task log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -121,7 +117,7 @@ class Silence(commands.Cog): If the channel was silenced indefinitely, notifications for the channel will stop. """ - await self._get_instance_vars_event.wait() + await self._get_instance_vars_task log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) -- cgit v1.2.3 From 0a6415068b782d511cafa32002922061a61b9f67 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:43:33 -0700 Subject: Silence: rename _get_instance_vars to _init_cog It's a more accurate name since it also reschedules unsilences now. --- bot/cogs/moderation/silence.py | 10 +++++----- tests/bot/cogs/moderation/test_silence.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 4910c7009..de799f64f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -71,10 +71,10 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) + self._init_task = self.bot.loop.create_task(self._init_cog()) - async def _get_instance_vars(self) -> None: - """Get instance variables after they're available to get from the guild.""" + async def _init_cog(self) -> None: + """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) @@ -92,7 +92,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ - await self._get_instance_vars_task + await self._init_task log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -117,7 +117,7 @@ class Silence(commands.Cog): If the channel was silenced indefinitely, notifications for the channel will stop. """ - await self._get_instance_vars_task + await self._init_task log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ab3d0742a..7c6efbfe4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -83,19 +83,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() self.bot.wait_until_guild_available.assert_called_once() self.bot.get_guild.assert_called_once_with(Guild.id) async def test_instance_vars_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) async def test_instance_vars_got_channels(self): """Got channels from bot.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @@ -104,7 +104,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._get_instance_vars() + await self.cog._init_cog() notifier.assert_called_once_with(mod_log) self.bot.get_channel.side_effect = None -- cgit v1.2.3 From 2300b66c09ac853fbf332ad1bbdd291d6d0c1d87 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:27:24 -0700 Subject: Tests: optionally prevent autospec helper from passing mocks Not everything that's decorated needs the mocks that are patched. Being required to add the args to the test function anyway is annoying. It's especially bad if trying to decorate an entire test suite, as every test would need the args. Move the definition to a separate module to keep things cleaner. --- tests/_autospec.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/helpers.py | 21 ++---------------- 2 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 tests/_autospec.py diff --git a/tests/_autospec.py b/tests/_autospec.py new file mode 100644 index 000000000..ee2fc1973 --- /dev/null +++ b/tests/_autospec.py @@ -0,0 +1,64 @@ +import contextlib +import functools +import unittest.mock +from typing import Callable + + +@functools.wraps(unittest.mock._patch.decoration_helper) +@contextlib.contextmanager +def _decoration_helper(self, patched, args, keywargs): + """Skips adding patchings as args if their `dont_pass` attribute is True.""" + # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added. + extra_args = [] + with contextlib.ExitStack() as exit_stack: + for patching in patched.patchings: + arg = exit_stack.enter_context(patching) + if not getattr(patching, "dont_pass", False): + # Only add the patching as an arg if dont_pass is False. + if patching.attribute_name is not None: + keywargs.update(arg) + elif patching.new is unittest.mock.DEFAULT: + extra_args.append(arg) + + args += tuple(extra_args) + yield args, keywargs + + +@functools.wraps(unittest.mock._patch.copy) +def _copy(self): + """Copy the `dont_pass` attribute along with the standard copy operation.""" + patcher_copy = _copy.original(self) + patcher_copy.dont_pass = getattr(self, "dont_pass", False) + return patcher_copy + + +# Monkey-patch the patcher class :) +_copy.original = unittest.mock._patch.copy +unittest.mock._patch.copy = _copy +unittest.mock._patch.decoration_helper = _decoration_helper + + +def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable: + """ + Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. + + If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object. + """ + # Caller's kwargs should take priority and overwrite the defaults. + kwargs = dict(spec_set=True, autospec=True) + kwargs.update(patch_kwargs) + + # Import the target if it's a string. + # This is to support both object and string targets like patch.multiple. + if type(target) is str: + target = unittest.mock._importer(target) + + def decorator(func): + for attribute in attributes: + patcher = unittest.mock.patch.object(target, attribute, **kwargs) + if not pass_mocks: + # A custom attribute to keep track of which patchings should be skipped. + patcher.dont_pass = True + func = patcher(func) + return func + return decorator diff --git a/tests/helpers.py b/tests/helpers.py index facc4e1af..6cf5d12bd 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ import itertools import logging import unittest.mock from asyncio import AbstractEventLoop -from typing import Callable, Iterable, Optional +from typing import Iterable, Optional import discord from aiohttp import ClientSession @@ -14,6 +14,7 @@ from discord.ext.commands import Context from bot.api import APIClient from bot.async_stats import AsyncStatsClient from bot.bot import Bot +from tests._autospec import autospec # noqa: F401 other modules import it via this module for logger in logging.Logger.manager.loggerDict.values(): @@ -26,24 +27,6 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) -def autospec(target, *attributes: str, **kwargs) -> Callable: - """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" - # Caller's kwargs should take priority and overwrite the defaults. - kwargs = {'spec_set': True, 'autospec': True, **kwargs} - - # Import the target if it's a string. - # This is to support both object and string targets like patch.multiple. - if type(target) is str: - target = unittest.mock._importer(target) - - def decorator(func): - for attribute in attributes: - patcher = unittest.mock.patch.object(target, attribute, **kwargs) - func = patcher(func) - return func - return decorator - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. -- cgit v1.2.3 From 0ffa80717d9db89ab6ed8865741c39820d33b392 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:11:21 -0700 Subject: Silence tests: mock RedisCaches --- tests/bot/cogs/moderation/test_silence.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 7c6efbfe4..8dfebb95c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -6,7 +6,7 @@ from discord import PermissionOverwrite from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles -from tests.helpers import MockBot, MockContext, MockTextChannel +from tests.helpers import MockBot, MockContext, MockTextChannel, autospec class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @@ -72,14 +72,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None - # Set event so command callbacks can continue. - self.cog._get_instance_vars_event.set() async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" -- cgit v1.2.3 From 19f801289b14740017687a72d106875276507a1c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:12:34 -0700 Subject: Silence tests: rename test_instance_vars to test_init_cog --- tests/bot/cogs/moderation/test_silence.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 8dfebb95c..67a61382c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -80,26 +80,26 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None - async def test_instance_vars_got_guild(self): + async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() - self.bot.wait_until_guild_available.assert_called_once() + self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - async def test_instance_vars_got_role(self): + async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - async def test_instance_vars_got_channels(self): + async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") - async def test_instance_vars_got_notifier(self, notifier): + async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) -- cgit v1.2.3 From de92d8553de25cc1ec45a5e4dbee8fd4d3426fa8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:15:02 -0700 Subject: Silence tests: replace obsolete cog_unload tests Moderation notifications are no longer sent so that doesn't need to be tested. --- tests/bot/cogs/moderation/test_silence.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 67a61382c..807bb09a0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -74,6 +74,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): + @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) @@ -237,20 +238,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): del mock_permissions_dict['send_messages'] self.assertDictEqual(mock_permissions_dict, new_permissions) - @mock.patch("bot.cogs.moderation.silence.asyncio") - @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): - """Task for sending an alert was created with present `muted_channels`.""" - with mock.patch.object(self.cog, "muted_channels"): - self.cog.cog_unload() - alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload_skips_task_start(self, asyncio_mock): - """No task created with no channels.""" + def test_cog_unload_cancels_tasks(self): + """All scheduled tasks should be cancelled.""" self.cog.cog_unload() - asyncio_mock.create_task.assert_not_called() + self.cog.scheduler.cancel_all.assert_called_once_with() @mock.patch("bot.cogs.moderation.silence.with_role_check") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 3182bacc342fd87313ffb22742947576d887f73b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:18:58 -0700 Subject: Silence tests: fix silence cache test for overwrites --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 807bb09a0..6f0cd880d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -184,12 +184,15 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_added_muted_channel(self): - """Channel was added to `muted_channels` on silence.""" + async def test_silence_private_cached_perms(self): + """Channel's previous overwrites were cached when silenced.""" channel = MockTextChannel() - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(channel, False, None) - muted_channels.add.assert_called_once_with(channel) + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + overwrite_json = '{"send_messages": true, "add_reactions": null}' + channel.overwrites_for.return_value = overwrite + + await self.cog._silence(channel, False, None) + self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" -- cgit v1.2.3 From ccbc515cfedd5938f4d1d50404a1b32d66bd5511 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:29:16 -0700 Subject: Silence tests: fix test_unsilence_private_for_false --- tests/bot/cogs/moderation/test_silence.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f0cd880d..c1039f85c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -196,7 +196,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" - channel = Mock() + self.cog.scheduler.__contains__.return_value = False + self.cog.muted_channel_perms.get.return_value = None + channel = MockTextChannel() + self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() -- cgit v1.2.3 From 28f7f66e4e9543d79f5a64ee6a0e0bbc80088804 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:33:47 -0700 Subject: Silence tests: fix test_silence_private_notifier --- tests/bot/cogs/moderation/test_silence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c1039f85c..e2e3ca9c1 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -174,6 +174,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + channel.overwrites_for.return_value = overwrite + with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): await self.cog._silence(channel, True, None) -- cgit v1.2.3 From c24fa4371c9e8b13431b5d5f3808401dda8d449a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:20:05 -0700 Subject: Silence tests: fix test_silence_private_silenced_channel --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index e2e3ca9c1..75b4ef470 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -151,11 +151,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked.""" + """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" channel = MockTextChannel() + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + channel.overwrites_for.return_value = overwrite + self.assertTrue(await self.cog._silence(channel, False, None)) - channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + self.assertFalse(overwrite.send_messages) + self.assertFalse(overwrite.add_reactions) + channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, + overwrite=overwrite + ) async def test_silence_private_preserves_permissions(self): """Previous permissions were preserved when channel was silenced.""" -- cgit v1.2.3 From 830bb36654103474fa74505f3e0ff4bdf91656fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:28:02 -0700 Subject: Silence tests: fix test_silence_private_for_false --- tests/bot/cogs/moderation/test_silence.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 75b4ef470..5763c4cdd 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -144,11 +144,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" - perm_overwrite = Mock(send_messages=False) - channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + subtests = ( + (False, PermissionOverwrite(send_messages=False, add_reactions=False)), + (True, PermissionOverwrite(send_messages=True, add_reactions=True)), + (True, PermissionOverwrite(send_messages=False, add_reactions=False)), + ) - self.assertFalse(await self.cog._silence(channel, True, None)) - channel.set_permissions.assert_not_called() + for contains, overwrite in subtests: + with self.subTest(contains=contains, overwrite=overwrite): + self.cog.scheduler.__contains__.return_value = contains + channel = MockTextChannel() + channel.overwrites_for.return_value = overwrite + + self.assertFalse(await self.cog._silence(channel, True, None)) + channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" -- cgit v1.2.3 From f8485f17ac0263c47365893438b3f1da609cb259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:47:28 -0700 Subject: Silence tests: fix command message tests --- tests/bot/cogs/moderation/test_silence.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5763c4cdd..02964d7ab 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -115,15 +115,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) - for duration, result_message, _silence_patch_return in test_cases: - with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return - ): - with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): + for duration, message, was_silenced in test_cases: + self.cog._init_task = mock.AsyncMock()() + with self.subTest(was_silenced=was_silenced, message=message, duration=duration): + with mock.patch.object(self.cog, "_silence", return_value=was_silenced): await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(result_message) + self.ctx.send.assert_called_once_with(message) self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): @@ -132,14 +129,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (True, f"{Emojis.check_mark} unsilenced current channel."), (False, f"{Emojis.cross_mark} current channel was not silenced.") ) - for _unsilence_patch_return, result_message in test_cases: - with self.subTest( - starting_silenced_state=_unsilence_patch_return, - result_message=result_message - ): - with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): + for was_unsilenced, message in test_cases: + self.cog._init_task = mock.AsyncMock()() + with self.subTest(was_unsilenced=was_unsilenced, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.assert_called_once_with(result_message) + self.ctx.channel.send.assert_called_once_with(message) self.ctx.reset_mock() async def test_silence_private_for_false(self): -- cgit v1.2.3 From 89107eccca3213c436028b997bdc6785fa9ce02d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:09:31 -0700 Subject: Silence tests: fix overwrite preservation test for silences --- tests/bot/cogs/moderation/test_silence.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 02964d7ab..765c324d2 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -168,19 +168,23 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=overwrite ) - async def test_silence_private_preserves_permissions(self): - """Previous permissions were preserved when channel was silenced.""" + async def test_silence_private_preserves_other_overwrites(self): + """Channel's other unrelated overwrites were not changed when it was silenced.""" channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite() - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions + overwrite = PermissionOverwrite(stream=True, attach_files=False) + channel.overwrites_for.return_value = overwrite + + prev_overwrite_dict = dict(overwrite) await self.cog._silence(channel, False, None) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) + new_overwrite_dict = dict(overwrite) + + # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" -- cgit v1.2.3 From eff788aab84ee96408823eb61ebafba2d9e9cbcb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:39:00 -0700 Subject: Silence tests: fix test_unsilence_private_removed_notifier --- tests/bot/cogs/moderation/test_silence.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 765c324d2..b21f5f61a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -233,8 +233,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + channel = MockTextChannel() + channel.overwrites_for.return_value = PermissionOverwrite() + await self.cog._unsilence(channel) notifier.remove_channel.assert_called_once_with(channel) -- cgit v1.2.3 From 3ec17dac3fe8e00f34489a5e3dd927bec39e91e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:58:31 -0700 Subject: Silence tests: fix tests for _unsilence Add a fixture to set up mocks for a successful `unsilence` call. This reduces code redundancy. --- tests/bot/cogs/moderation/test_silence.py | 75 ++++++++++++++++++------------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b21f5f61a..ff32e9a05 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,6 @@ import unittest from unittest import mock -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock from discord import PermissionOverwrite @@ -81,6 +81,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None + def unsilence_fixture(self) -> MockTextChannel: + """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + + # stream=True just to have at least one other overwrite not be the default value. + channel = MockTextChannel() + overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + channel.overwrites_for.return_value = overwrite + + return channel + async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() @@ -223,47 +235,50 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertTrue(await self.cog._unsilence(channel)) - channel.set_permissions.assert_called_once() - self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) + """Channel had `send_message` permissions restored.""" + channel = self.unsilence_fixture() + overwrite = channel.overwrites_for.return_value + + await self.cog._unsilence(channel) + channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, overwrite=overwrite + ) + + # Recall that these values are determined by the fixture. + self.assertTrue(overwrite.send_messages) + self.assertIsNone(overwrite.add_reactions) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json - channel = MockTextChannel() - channel.overwrites_for.return_value = PermissionOverwrite() - + channel = self.unsilence_fixture() await self.cog._unsilence(channel) notifier.remove_channel.assert_called_once_with(channel) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from `muted_channels` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._unsilence(channel) - muted_channels.discard.assert_called_once_with(channel) + """Channel was removed from overwrites cache on unsilence.""" + channel = self.unsilence_fixture() + await self.cog._unsilence(channel) + self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_permissions(self, _): - """Previous permissions were preserved when channel was unsilenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite(send_messages=False) - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions + async def test_unsilence_private_preserves_other_overwrites(self, _): + """Channel's other unrelated overwrites were not changed when it was unsilenced.""" + channel = self.unsilence_fixture() + overwrite = channel.overwrites_for.return_value + + prev_overwrite_dict = dict(overwrite) await self.cog._unsilence(channel) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) + new_overwrite_dict = dict(overwrite) + + # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) def test_cog_unload_cancels_tasks(self): """All scheduled tasks should be cancelled.""" -- cgit v1.2.3 From 0adaf09af5c7a76566f874eda429dd6f263189be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 16:33:39 -0700 Subject: Silence tests: separate test cases; refactor names & docstrings --- tests/bot/cogs/moderation/test_silence.py | 166 +++++++++++++++++------------- 1 file changed, 95 insertions(+), 71 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ff32e9a05..f3ee184d4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,3 +1,4 @@ +import asyncio import unittest from unittest import mock from unittest.mock import Mock @@ -73,25 +74,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) -class SilenceTests(unittest.IsolatedAsyncioTestCase): +class SilenceCogTests(unittest.IsolatedAsyncioTestCase): + """Tests for the general functionality of the Silence cog.""" + @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.ctx = MockContext() - self.cog._verified_role = None - - def unsilence_fixture(self) -> MockTextChannel: - """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json - - # stream=True just to have at least one other overwrite not be the default value. - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - channel.overwrites_for.return_value = overwrite - - return channel async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" @@ -120,37 +109,49 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): notifier.assert_called_once_with(mod_log) self.bot.get_channel.side_effect = None - async def test_silence_sent_correct_discord_message(self): - """Check if proper message was sent when called with duration in channel with previous state.""" + def test_cog_unload_cancelled_tasks(self): + """All scheduled tasks were cancelled.""" + self.cog.cog_unload() + self.cog.scheduler.cancel_all.assert_called_once_with() + + @mock.patch("bot.cogs.moderation.silence.with_role_check") + @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check was called with `MODERATION_ROLES`""" + ctx = MockContext() + self.cog.cog_check(ctx) + role_check.assert_called_once_with(ctx, *(1, 2, 3)) + + +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class SilenceTests(unittest.IsolatedAsyncioTestCase): + """Tests for the silence command and its related helper methods.""" + + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.cog._init_task = mock.AsyncMock()() + + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + async def test_sent_correct_message(self): + """Appropriate failure/success message was sent by the command.""" test_cases = ( (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) for duration, message, was_silenced in test_cases: - self.cog._init_task = mock.AsyncMock()() + ctx = MockContext() with self.subTest(was_silenced=was_silenced, message=message, duration=duration): with mock.patch.object(self.cog, "_silence", return_value=was_silenced): - await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(message) - self.ctx.reset_mock() + await self.cog.silence.callback(self.cog, ctx, duration) + ctx.send.assert_called_once_with(message) - async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent when unsilencing channel.""" - test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") - ) - for was_unsilenced, message in test_cases: - self.cog._init_task = mock.AsyncMock()() - with self.subTest(was_unsilenced=was_unsilenced, message=message): - with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): - await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.channel.send.assert_called_once_with(message) - self.ctx.reset_mock() - - async def test_silence_private_for_false(self): - """Permissions are not set and `False` is returned in an already silenced channel.""" + async def test_skipped_already_silenced(self): + """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( (False, PermissionOverwrite(send_messages=False, add_reactions=False)), (True, PermissionOverwrite(send_messages=True, add_reactions=True)), @@ -166,7 +167,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._silence(channel, True, None)) channel.set_permissions.assert_not_called() - async def test_silence_private_silenced_channel(self): + async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) @@ -180,8 +181,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=overwrite ) - async def test_silence_private_preserves_other_overwrites(self): - """Channel's other unrelated overwrites were not changed when it was silenced.""" + async def test_preserved_other_overwrites(self): + """Channel's other unrelated overwrites were not changed.""" channel = MockTextChannel() overwrite = PermissionOverwrite(stream=True, attach_files=False) channel.overwrites_for.return_value = overwrite @@ -198,8 +199,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_silence_private_notifier(self): - """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + async def test_added_removed_notifier(self): + """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) channel.overwrites_for.return_value = overwrite @@ -214,8 +215,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_cached_perms(self): - """Channel's previous overwrites were cached when silenced.""" + async def test_cached_previous_overwrites(self): + """Channel's previous overwrites were cached.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) overwrite_json = '{"send_messages": true, "add_reactions": null}' @@ -224,8 +225,47 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) - async def test_unsilence_private_for_false(self): - """Permissions are not set and `False` is returned in an unsilenced channel.""" + +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class UnsilenceTests(unittest.IsolatedAsyncioTestCase): + """Tests for the unsilence command and its related helper methods.""" + + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.cog._init_task = mock.AsyncMock()() + + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + def unsilence_fixture(self) -> MockTextChannel: + """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + + # stream=True just to have at least one other overwrite not be the default value. + channel = MockTextChannel() + overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + channel.overwrites_for.return_value = overwrite + + return channel + + async def test_sent_correct_message(self): + """Appropriate failure/success message was sent by the command.""" + test_cases = ( + (True, f"{Emojis.check_mark} unsilenced current channel."), + (False, f"{Emojis.cross_mark} current channel was not silenced.") + ) + for was_unsilenced, message in test_cases: + ctx = MockContext() + with self.subTest(was_unsilenced=was_unsilenced, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + await self.cog.unsilence.callback(self.cog, ctx) + ctx.channel.send.assert_called_once_with(message) + + async def test_skipped_already_unsilenced(self): + """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False self.cog.muted_channel_perms.get.return_value = None channel = MockTextChannel() @@ -233,9 +273,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored.""" + async def test_unsilenced_channel(self): + """Channel's `send_message` and `add_reactions` overwrites were restored.""" channel = self.unsilence_fixture() overwrite = channel.overwrites_for.return_value @@ -248,23 +287,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(overwrite.send_messages) self.assertIsNone(overwrite.add_reactions) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_notifier(self, notifier): - """Channel was removed from `notifier` on unsilence.""" + async def test_removed_notifier(self): + """Channel was removed from `notifier`.""" channel = self.unsilence_fixture() await self.cog._unsilence(channel) - notifier.remove_channel.assert_called_once_with(channel) + self.cog.notifier.remove_channel.assert_called_once_with(channel) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from overwrites cache on unsilence.""" + async def test_deleted_cached_overwrite(self): + """Channel was deleted from the overwrites cache.""" channel = self.unsilence_fixture() await self.cog._unsilence(channel) self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_other_overwrites(self, _): - """Channel's other unrelated overwrites were not changed when it was unsilenced.""" + async def test_preserved_other_overwrites(self): + """Channel's other unrelated overwrites were not changed.""" channel = self.unsilence_fixture() overwrite = channel.overwrites_for.return_value @@ -279,15 +315,3 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): del new_overwrite_dict['add_reactions'] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - - def test_cog_unload_cancels_tasks(self): - """All scheduled tasks should be cancelled.""" - self.cog.cog_unload() - self.cog.scheduler.cancel_all.assert_called_once_with() - - @mock.patch("bot.cogs.moderation.silence.with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From 3f4d409bd951e749bdc643b0bc288cfc0f5136bd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 16:46:13 -0700 Subject: Silence tests: autospec _reschedule and SilenceNotifier for cog tests --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index f3ee184d4..ae8b59ff5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -82,39 +82,45 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = Silence(self.bot) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) await self.cog._init_cog() - notifier.assert_called_once_with(mod_log) - self.bot.get_channel.side_effect = None + notifier.assert_called_once_with(self.cog._mod_log_channel) def test_cog_unload_cancelled_tasks(self): """All scheduled tasks were cancelled.""" self.cog.cog_unload() self.cog.scheduler.cancel_all.assert_called_once_with() - @mock.patch("bot.cogs.moderation.silence.with_role_check") + @autospec("bot.cogs.moderation.silence", "with_role_check") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" -- cgit v1.2.3 From a13bf79ba2aa672e52bcffe38720c7686cac67ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 18:40:36 -0700 Subject: Silence tests: merge unsilence fixture into setUp Now that there are separate test cases, there's no need to keep the fixtures separate. --- tests/bot/cogs/moderation/test_silence.py | 52 ++++++++++++------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ae8b59ff5..fe6045c87 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -232,7 +232,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" @@ -243,19 +243,15 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog = Silence(self.bot) self.cog._init_task = mock.AsyncMock()() - asyncio.run(self.cog._init_cog()) # Populate instance attributes. - - def unsilence_fixture(self) -> MockTextChannel: - """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json + perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) + self.cog.muted_channel_perms = perms_cache - # stream=True just to have at least one other overwrite not be the default value. - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - channel.overwrites_for.return_value = overwrite + asyncio.run(self.cog._init_cog()) # Populate instance attributes. - return channel + perms_cache.get.return_value = '{"send_messages": true, "add_reactions": null}' + self.channel = MockTextChannel() + self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + self.channel.overwrites_for.return_value = self.overwrite async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" @@ -281,38 +277,30 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_unsilenced_channel(self): """Channel's `send_message` and `add_reactions` overwrites were restored.""" - channel = self.unsilence_fixture() - overwrite = channel.overwrites_for.return_value - - await self.cog._unsilence(channel) - channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, overwrite=overwrite + await self.cog._unsilence(self.channel) + self.channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, overwrite=self.overwrite ) # Recall that these values are determined by the fixture. - self.assertTrue(overwrite.send_messages) - self.assertIsNone(overwrite.add_reactions) + self.assertTrue(self.overwrite.send_messages) + self.assertIsNone(self.overwrite.add_reactions) async def test_removed_notifier(self): """Channel was removed from `notifier`.""" - channel = self.unsilence_fixture() - await self.cog._unsilence(channel) - self.cog.notifier.remove_channel.assert_called_once_with(channel) + await self.cog._unsilence(self.channel) + self.cog.notifier.remove_channel.assert_called_once_with(self.channel) async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" - channel = self.unsilence_fixture() - await self.cog._unsilence(channel) - self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) + await self.cog._unsilence(self.channel) + self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - channel = self.unsilence_fixture() - overwrite = channel.overwrites_for.return_value - - prev_overwrite_dict = dict(overwrite) - await self.cog._unsilence(channel) - new_overwrite_dict = dict(overwrite) + prev_overwrite_dict = dict(self.overwrite) + await self.cog._unsilence(self.channel) + new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] -- cgit v1.2.3 From 8c2945f9fee9f6041edaea5b5c98209478b33259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 18:46:27 -0700 Subject: Silence tests: create channel and overwrite in setUp for silence tests Reduce code redundancy by only defining them once. --- tests/bot/cogs/moderation/test_silence.py | 46 ++++++++++++------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fe6045c87..eba8385bc 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -142,6 +142,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._init_cog()) # Populate instance attributes. + self.channel = MockTextChannel() + self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) + self.channel.overwrites_for.return_value = self.overwrite + async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( @@ -175,27 +179,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - channel.overwrites_for.return_value = overwrite - - self.assertTrue(await self.cog._silence(channel, False, None)) - self.assertFalse(overwrite.send_messages) - self.assertFalse(overwrite.add_reactions) - channel.set_permissions.assert_awaited_once_with( + self.assertTrue(await self.cog._silence(self.channel, False, None)) + self.assertFalse(self.overwrite.send_messages) + self.assertFalse(self.overwrite.add_reactions) + self.channel.set_permissions.assert_awaited_once_with( self.cog._verified_role, - overwrite=overwrite + overwrite=self.overwrite ) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, attach_files=False) - channel.overwrites_for.return_value = overwrite - - prev_overwrite_dict = dict(overwrite) - await self.cog._silence(channel, False, None) - new_overwrite_dict = dict(overwrite) + prev_overwrite_dict = dict(self.overwrite) + await self.cog._silence(self.channel, False, None) + new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] @@ -207,29 +203,21 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_added_removed_notifier(self): """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - channel.overwrites_for.return_value = overwrite - with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): - await self.cog._silence(channel, True, None) + await self.cog._silence(self.channel, True, None) self.cog.notifier.add_channel.assert_called_once() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=False): - await self.cog._silence(channel, False, None) + await self.cog._silence(self.channel, False, None) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - overwrite_json = '{"send_messages": true, "add_reactions": null}' - channel.overwrites_for.return_value = overwrite - - await self.cog._silence(channel, False, None) - self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) + overwrite_json = '{"send_messages": true, "add_reactions": false}' + await self.cog._silence(self.channel, False, None) + self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(Silence, "muted_channel_times", pass_mocks=False) -- cgit v1.2.3 From 282596d1414613e05ee8b956393913da976b35e3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:13:18 -0700 Subject: Silence tests: fix mock for _init_task An `AsyncMock` fails because it returns a coroutine which may only be awaited once. However, an `asyncio.Future` is perfect because it is easy to create and can be awaited repeatedly, just like the actual `asyncio.Task` that is being mocked. --- tests/bot/cogs/moderation/test_silence.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eba8385bc..5d42d8c36 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -138,7 +138,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.cog._init_task = mock.AsyncMock()() + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) asyncio.run(self.cog._init_cog()) # Populate instance attributes. @@ -229,7 +230,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.cog._init_task = mock.AsyncMock()() + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) self.cog.muted_channel_perms = perms_cache -- cgit v1.2.3 From eda342b40ccd941050a1421ef1907cb2790c1cde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:15:53 -0700 Subject: Silence tests: add a test for the time cache --- tests/bot/cogs/moderation/test_silence.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5d42d8c36..1ae17177f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,5 +1,6 @@ import asyncio import unittest +from datetime import datetime, timezone from unittest import mock from unittest.mock import Mock @@ -220,6 +221,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(self.channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) + @autospec("bot.cogs.moderation.silence", "datetime") + async def test_cached_unsilence_time(self, datetime_mock): + """The UTC POSIX timestamp for the unsilence was cached.""" + now_timestamp = 100 + duration = 15 + timestamp = now_timestamp + duration * 60 + datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc) + + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, duration) + + self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) + datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 5eec1c2db319ccdb1f71c1a25fa541eeb7a2707a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:17:51 -0700 Subject: Silence tests: add a test for caching permanent times --- tests/bot/cogs/moderation/test_silence.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1ae17177f..2e756a88f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -235,6 +235,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + async def test_cached_indefinite_time(self): + """A value of -1 was cached for a permanent silence.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, None) + self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 1e1d358ae38bb9d554e993fb61ee8f0b52f977b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:25:15 -0700 Subject: Silence tests: add tests for scheduling tasks --- tests/bot/cogs/moderation/test_silence.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2e756a88f..979b4f4e5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -241,6 +241,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, None) self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + async def test_scheduled_task(self): + """An unsilence task was scheduled.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx) + self.cog.scheduler.schedule_later.assert_called_once() + + async def test_permanent_not_scheduled(self): + """A task was not scheduled for a permanent silence.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, None) + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From d4fbd675d9803cc664909c19fcec8a430524f918 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:26:48 -0700 Subject: Silence tests: add a test for deletion from the time cache --- tests/bot/cogs/moderation/test_silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 979b4f4e5..6f913b8f9 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -319,6 +319,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) + async def test_deleted_cached_time(self): + """Channel was deleted from the timestamp cache.""" + await self.cog._unsilence(self.channel) + self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) -- cgit v1.2.3 From 67f88e0b63ec9ac198b8204d4b07e6f7ee67937b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:28:15 -0700 Subject: Silence tests: add a test for task cancellation --- tests/bot/cogs/moderation/test_silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f913b8f9..9e81df9c4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -324,6 +324,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + async def test_cancelled_task(self): + """The scheduled unsilence task should be cancelled.""" + await self.cog._unsilence(self.channel) + self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) -- cgit v1.2.3 From cc956f24e1f748dbe97fc6bd96383d22a494c5ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:50:14 -0700 Subject: Silence tests: add a test for default overwrites on cache miss Use a False for `add_reactions` in the mock overwrite rather than None to be sure the default (also None) is actually set for it. Fix channels set by `_init_cog` not being mocked properly. --- tests/bot/cogs/moderation/test_silence.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 9e81df9c4..992906a50 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -261,7 +261,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "_reschedule", pass_mocks=False) @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: - self.bot = MockBot() + self.bot = MockBot(get_channel=lambda _: MockTextChannel()) self.cog = Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -271,7 +271,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._init_cog()) # Populate instance attributes. - perms_cache.get.return_value = '{"send_messages": true, "add_reactions": null}' + self.cog.scheduler.__contains__.return_value = True + perms_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) self.channel.overwrites_for.return_value = self.overwrite @@ -298,15 +299,29 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - async def test_unsilenced_channel(self): + async def test_restored_overwrites(self): """Channel's `send_message` and `add_reactions` overwrites were restored.""" await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, overwrite=self.overwrite + self.cog._verified_role, + overwrite=self.overwrite, ) # Recall that these values are determined by the fixture. self.assertTrue(self.overwrite.send_messages) + self.assertFalse(self.overwrite.add_reactions) + + async def test_cache_miss_used_default_overwrites(self): + """Both overwrites were set to None due previous values not being found in the cache.""" + self.cog.muted_channel_perms.get.return_value = None + + await self.cog._unsilence(self.channel) + self.channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, + overwrite=self.overwrite, + ) + + self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) async def test_removed_notifier(self): -- cgit v1.2.3 From e4548b2505cf4765cfd2a2c1a1762212cc5cba25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:54:04 -0700 Subject: Silence tests: add a test for a mod alert on cache miss --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 992906a50..ccc908ee4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -324,6 +324,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) + async def test_cache_miss_sent_mod_alert(self): + """A message was sent to the mod alerts channel.""" + self.cog.muted_channel_perms.get.return_value = None + + await self.cog._unsilence(self.channel) + self.cog._mod_alerts_channel.send.assert_awaited_once() + async def test_removed_notifier(self): """Channel was removed from `notifier`.""" await self.cog._unsilence(self.channel) -- cgit v1.2.3 From 33fb55cbe431211f99acfbce22129c48a60a1e6b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:58:26 -0700 Subject: Silence tests: also test that cache misses preserve other overwrites --- tests/bot/cogs/moderation/test_silence.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ccc908ee4..71608d3f9 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -352,15 +352,19 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed.""" - prev_overwrite_dict = dict(self.overwrite) - await self.cog._unsilence(self.channel) - new_overwrite_dict = dict(self.overwrite) - - # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. - del prev_overwrite_dict['send_messages'] - del prev_overwrite_dict['add_reactions'] - del new_overwrite_dict['send_messages'] - del new_overwrite_dict['add_reactions'] - - self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + """Channel's other unrelated overwrites were not changed, including cache misses.""" + for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): + with self.subTest(overwrite_json=overwrite_json): + self.cog.muted_channel_perms.get.return_value = overwrite_json + + prev_overwrite_dict = dict(self.overwrite) + await self.cog._unsilence(self.channel) + new_overwrite_dict = dict(self.overwrite) + + # Remove these keys because they were modified by the unsilence. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) -- cgit v1.2.3 From 83d74c56af2a777a2a4f6f7f5347598d0000a66b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:14:46 -0700 Subject: Silence tests: assert against message constants Duplicating strings in assertions is redundant, closely coupled, and less maintainable. --- bot/cogs/moderation/silence.py | 26 +++++++++++++++++--------- tests/bot/cogs/moderation/test_silence.py | 13 +++++++------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index de799f64f..9732248ff 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -17,6 +17,17 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." +MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." +MSG_SILENCE_SUCCESS = Emojis.check_mark + " silenced current channel for {duration} minute(s)." + +MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_MANUAL = ( + f"{Emojis.cross_mark} current channel was not unsilenced because the current " + f"overwrites were set manually. Please edit them manually to unsilence." +) +MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." + class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -96,15 +107,15 @@ class Silence(commands.Cog): log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") + await ctx.send(MSG_SILENCE_FAIL) return if duration is None: - await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + await ctx.send(MSG_SILENCE_PERMANENT) await self.muted_channel_times.set(ctx.channel.id, -1) return - await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") + await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) @@ -126,14 +137,11 @@ class Silence(commands.Cog): if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False and overwrite.add_reactions is False: - await channel.send( - f"{Emojis.cross_mark} current channel was not unsilenced because the current " - f"overwrites were set manually. Please edit them manually to unsilence." - ) + await channel.send(MSG_UNSILENCE_MANUAL) else: - await channel.send(f"{Emojis.cross_mark} current channel was not silenced.") + await channel.send(MSG_UNSILENCE_FAIL) else: - await channel.send(f"{Emojis.check_mark} unsilenced current channel.") + await channel.send(MSG_UNSILENCE_SUCCESS) async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 71608d3f9..168794b6f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -6,8 +6,9 @@ from unittest.mock import Mock from discord import PermissionOverwrite +from bot.cogs.moderation import silence from bot.cogs.moderation.silence import Silence, SilenceNotifier -from bot.constants import Channels, Emojis, Guild, Roles +from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec @@ -151,9 +152,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( - (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), - (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), + (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,), + (None, silence.MSG_SILENCE_PERMANENT, True,), + (5, silence.MSG_SILENCE_FAIL, False,), ) for duration, message, was_silenced in test_cases: ctx = MockContext() @@ -280,8 +281,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") + (True, silence.MSG_UNSILENCE_SUCCESS), + (False, silence.MSG_UNSILENCE_FAIL) ) for was_unsilenced, message in test_cases: ctx = MockContext() -- cgit v1.2.3 From ed30502710e805de5e3793b762a3848a0295582d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:18:14 -0700 Subject: Silence tests: add a subtest for the manual unsilence message --- tests/bot/cogs/moderation/test_silence.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 168794b6f..254480a6d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -280,14 +280,17 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" + unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True) test_cases = ( - (True, silence.MSG_UNSILENCE_SUCCESS), - (False, silence.MSG_UNSILENCE_FAIL) + (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), + (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), ) - for was_unsilenced, message in test_cases: + for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() - with self.subTest(was_unsilenced=was_unsilenced, message=message): + with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite): with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + ctx.channel.overwrites_for.return_value = overwrite await self.cog.unsilence.callback(self.cog, ctx) ctx.channel.send.assert_called_once_with(message) -- cgit v1.2.3 From f9d4081efc41c7ab9f5e6362a0ab4fab5bc88cd8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:23:15 -0700 Subject: Silence tests: access everything via the silence module The module is imported anyway to keep imports short and clean. Using it in patch targets is shorter and allows for the two imports from the module to be removed. --- tests/bot/cogs/moderation/test_silence.py | 47 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 254480a6d..0a93cc623 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -7,7 +7,6 @@ from unittest.mock import Mock from discord import PermissionOverwrite from bot.cogs.moderation import silence -from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec @@ -15,7 +14,7 @@ from tests.helpers import MockBot, MockContext, MockTextChannel, autospec class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() - self.notifier = SilenceNotifier(self.alert_channel) + self.notifier = silence.SilenceNotifier(self.alert_channel) self.notifier.stop = self.notifier_stop_mock = Mock() self.notifier.start = self.notifier_start_mock = Mock() @@ -75,41 +74,41 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests for the general functionality of the Silence cog.""" - @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) + @autospec(silence, "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier") + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() @@ -122,8 +121,8 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog.cog_unload() self.cog.scheduler.cancel_all.assert_called_once_with() - @autospec("bot.cogs.moderation.silence", "with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + @autospec(silence, "with_role_check") + @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" ctx = MockContext() @@ -131,15 +130,15 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -222,7 +221,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(self.channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) - @autospec("bot.cogs.moderation.silence", "datetime") + @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): """The UTC POSIX timestamp for the unsilence was cached.""" now_timestamp = 100 @@ -255,15 +254,15 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() -@autospec(Silence, "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot(get_channel=lambda _: MockTextChannel()) - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) -- cgit v1.2.3 From cbdc14a3abbcbee644e9d4a6f3ffde125a7c91f1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:36:48 -0700 Subject: Silence tests: remove _reschedule patch for cog tests They don't do anything because they patch the class rather than the instance. It's too late for patching the instance to work since the `setUp` fixture, which instantiates the cog, executes before the patches do. Patching `setUp` would work (and its done in the other test cases), but some tests in this case will need the unpatched function too. Patching it doesn't serve much benefit to most tests anyway, so it's not worth the effort trying to make them work where they aren't needed. --- tests/bot/cogs/moderation/test_silence.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 0a93cc623..667d61776 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -83,7 +83,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = silence.Silence(self.bot) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" @@ -91,7 +90,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" @@ -99,7 +97,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" @@ -107,7 +104,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" -- cgit v1.2.3 From 366f975bbb22c45b9e071644ab4053416bf351fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:37:24 -0700 Subject: Silence tests: add a test for _init_cog rescheduling unsilences --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 667d61776..5deed2d0b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -112,6 +112,13 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._init_cog() notifier.assert_called_once_with(self.cog._mod_log_channel) + @autospec(silence, "SilenceNotifier", pass_mocks=False) + async def test_init_cog_rescheduled(self): + """`_reschedule_` coroutine was awaited.""" + self.cog._reschedule = mock.create_autospec(self.cog._reschedule, spec_set=True) + await self.cog._init_cog() + self.cog._reschedule.assert_awaited_once_with() + def test_cog_unload_cancelled_tasks(self): """All scheduled tasks were cancelled.""" self.cog.cog_unload() -- cgit v1.2.3 From d5032459bfe1bbbc10ffc3b95809e0fc377de60c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:19:17 -0700 Subject: Silence tests: test the scheduler skips missing channels --- tests/bot/cogs/moderation/test_silence.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5deed2d0b..2c8059752 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -133,6 +133,31 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class RescheduleTests(unittest.IsolatedAsyncioTestCase): + """Tests for the rescheduling of cached unsilences.""" + + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self): + self.bot = MockBot() + self.cog = silence.Silence(self.bot) + self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper, spec_set=True) + + with mock.patch.object(self.cog, "_reschedule", spec_set=True, autospec=True): + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + async def test_skipped_missing_channel(self): + """Did nothing because the channel couldn't be retrieved.""" + self.cog.muted_channel_times.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.bot.get_channel.return_value = None + + await self.cog._reschedule() + + self.cog.notifier.add_channel.assert_not_called() + self.cog._unsilence_wrapper.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" -- cgit v1.2.3 From d44e3f795f8c56a1fbbce2833b27474b263e911b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:53:33 -0700 Subject: Silence tests: test the rescheduler adds permanent silence to notifier --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2c8059752..6e8c9ff38 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -157,6 +157,20 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + async def test_added_permanent_to_notifier(self): + """Permanently silenced channels were added to the notifier.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, -1), (456, -1)] + + await self.cog._reschedule() + + self.cog.notifier.add_channel.assert_any_call(channels[0]) + self.cog.notifier.add_channel.assert_any_call(channels[1]) + + self.cog._unsilence_wrapper.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 5f23f6630cd1c44d129d23e4becd9fce7f76135d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:57:17 -0700 Subject: Silence tests: test the rescheduler unsilences expired silences --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6e8c9ff38..d9ff13595 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -171,6 +171,20 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + async def test_unsilenced_expired(self): + """Unsilenced expired silences.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, 100), (456, 200)] + + await self.cog._reschedule() + + self.cog._unsilence_wrapper.assert_any_call(channels[0]) + self.cog._unsilence_wrapper.assert_any_call(channels[1]) + + self.cog.notifier.add_channel.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 7fadf2d531562dcc7e78bcb70d59d0a0575a18be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 11:56:49 -0700 Subject: Silence tests: add a test for rescheduling active silences --- tests/bot/cogs/moderation/test_silence.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d9ff13595..3d111341b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -11,6 +11,13 @@ from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec +# Have to subclass it because builtins can't be patched. +class PatchedDatetime(datetime): + """A datetime object with a mocked now() function.""" + + now = mock.create_autospec(datetime, "now") + + class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() @@ -185,6 +192,28 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + @mock.patch.object(silence, "datetime", new=PatchedDatetime) + async def test_rescheduled_active(self): + """Rescheduled active silences.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, 2000), (456, 3000)] + silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) + + self.cog._unsilence_wrapper = mock.MagicMock() + unsilence_return = self.cog._unsilence_wrapper.return_value + + await self.cog._reschedule() + + # Yuck. + calls = [mock.call(1000, 123, unsilence_return), mock.call(2000, 456, unsilence_return)] + self.cog.scheduler.schedule_later.assert_has_calls(calls) + + unsilence_calls = [mock.call(channel) for channel in channels] + self.cog._unsilence_wrapper.assert_has_calls(unsilence_calls) + + self.cog.notifier.add_channel.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From a9ddbf346d95e67731196d8d822835330b6992af Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:02:29 -0700 Subject: Silence tests: more accurately assert the silence cmd schedule a task --- tests/bot/cogs/moderation/test_silence.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3d111341b..bc41422ef 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -328,9 +328,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_scheduled_task(self): """An unsilence task was scheduled.""" - ctx = MockContext(channel=self.channel) - await self.cog.silence.callback(self.cog, ctx) - self.cog.scheduler.schedule_later.assert_called_once() + ctx = MockContext(channel=self.channel, invoke=mock.MagicMock()) + + await self.cog.silence.callback(self.cog, ctx, 5) + + args = (300, ctx.channel.id, ctx.invoke.return_value) + self.cog.scheduler.schedule_later.assert_called_once_with(*args) + ctx.invoke.assert_called_once_with(self.cog.unsilence) async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" -- cgit v1.2.3 From 40c6f688eb0e317b1489b069f263f25b202a345c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:15:36 -0700 Subject: Silence tests: remove unnecessary spec_set args It's not really necessary to set to True when mocking functions. --- tests/bot/cogs/moderation/test_silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index bc41422ef..5c6d677ca 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -122,7 +122,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_rescheduled(self): """`_reschedule_` coroutine was awaited.""" - self.cog._reschedule = mock.create_autospec(self.cog._reschedule, spec_set=True) + self.cog._reschedule = mock.create_autospec(self.cog._reschedule) await self.cog._init_cog() self.cog._reschedule.assert_awaited_once_with() @@ -148,9 +148,9 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.cog = silence.Silence(self.bot) - self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper, spec_set=True) + self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) - with mock.patch.object(self.cog, "_reschedule", spec_set=True, autospec=True): + with mock.patch.object(self.cog, "_reschedule", autospec=True): asyncio.run(self.cog._init_cog()) # Populate instance attributes. async def test_skipped_missing_channel(self): -- cgit v1.2.3 From 27a00bf29193b1768c298c1455936bb7dc92aaf1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:18:14 -0700 Subject: Silence: rename caches --- bot/cogs/moderation/silence.py | 18 +++++++------- tests/bot/cogs/moderation/test_silence.py | 40 +++++++++++++++---------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 9732248ff..5851be00a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -72,11 +72,11 @@ class Silence(commands.Cog): # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. # Overwrites are stored as JSON. - muted_channel_perms = RedisCache() + previous_overwrites = RedisCache() # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. # A timestamp equal to -1 means it's indefinite. - muted_channel_times = RedisCache() + unsilence_timestamps = RedisCache() def __init__(self, bot: Bot): self.bot = bot @@ -112,14 +112,14 @@ class Silence(commands.Cog): if duration is None: await ctx.send(MSG_SILENCE_PERMANENT) - await self.muted_channel_times.set(ctx.channel.id, -1) + await self.unsilence_timestamps.set(ctx.channel.id, -1) return await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) - await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) + await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -160,7 +160,7 @@ class Silence(commands.Cog): overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) - await self.muted_channel_perms.set(channel.id, json.dumps(prev_overwrites)) + await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") @@ -180,7 +180,7 @@ class Silence(commands.Cog): Return `True` if channel permissions were changed, `False` otherwise. """ - prev_overwrites = await self.muted_channel_perms.get(channel.id) + prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False @@ -197,8 +197,8 @@ class Silence(commands.Cog): self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) - await self.muted_channel_perms.delete(channel.id) - await self.muted_channel_times.delete(channel.id) + await self.previous_overwrites.delete(channel.id) + await self.unsilence_timestamps.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( @@ -211,7 +211,7 @@ class Silence(commands.Cog): async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" - for channel_id, timestamp in await self.muted_channel_times.items(): + for channel_id, timestamp in await self.unsilence_timestamps.items(): channel = self.bot.get_channel(channel_id) if channel is None: log.info(f"Can't reschedule silence for {channel_id}: channel not found.") diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5c6d677ca..a66d27d08 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -81,7 +81,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests for the general functionality of the Silence cog.""" @@ -140,7 +140,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Tests for the rescheduling of cached unsilences.""" @@ -155,7 +155,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.muted_channel_times.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -168,7 +168,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Permanently silenced channels were added to the notifier.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, -1), (456, -1)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (456, -1)] await self.cog._reschedule() @@ -182,7 +182,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Unsilenced expired silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, 100), (456, 200)] + self.cog.unsilence_timestamps.items.return_value = [(123, 100), (456, 200)] await self.cog._reschedule() @@ -197,7 +197,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Rescheduled active silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, 2000), (456, 3000)] + self.cog.unsilence_timestamps.items.return_value = [(123, 2000), (456, 3000)] silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) self.cog._unsilence_wrapper = mock.MagicMock() @@ -215,7 +215,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" @@ -304,7 +304,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' await self.cog._silence(self.channel, False, None) - self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) + self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): @@ -317,14 +317,14 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext(channel=self.channel) await self.cog.silence.callback(self.cog, ctx, duration) - self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) + self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp) datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. async def test_cached_indefinite_time(self): """A value of -1 was cached for a permanent silence.""" ctx = MockContext(channel=self.channel) await self.cog.silence.callback(self.cog, ctx, None) - self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) async def test_scheduled_task(self): """An unsilence task was scheduled.""" @@ -343,7 +343,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() -@autospec(silence.Silence, "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" @@ -355,13 +355,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) - perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) - self.cog.muted_channel_perms = perms_cache + overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) + self.cog.previous_overwrites = overwrites_cache asyncio.run(self.cog._init_cog()) # Populate instance attributes. self.cog.scheduler.__contains__.return_value = True - perms_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' + overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) self.channel.overwrites_for.return_value = self.overwrite @@ -385,7 +385,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None channel = MockTextChannel() self.assertFalse(await self.cog._unsilence(channel)) @@ -405,7 +405,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cache_miss_used_default_overwrites(self): """Both overwrites were set to None due previous values not being found in the cache.""" - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( @@ -418,7 +418,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cache_miss_sent_mod_alert(self): """A message was sent to the mod alerts channel.""" - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(self.channel) self.cog._mod_alerts_channel.send.assert_awaited_once() @@ -431,12 +431,12 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" await self.cog._unsilence(self.channel) - self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) + self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id) async def test_deleted_cached_time(self): """Channel was deleted from the timestamp cache.""" await self.cog._unsilence(self.channel) - self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id) async def test_cancelled_task(self): """The scheduled unsilence task should be cancelled.""" @@ -447,7 +447,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's other unrelated overwrites were not changed, including cache misses.""" for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): with self.subTest(overwrite_json=overwrite_json): - self.cog.muted_channel_perms.get.return_value = overwrite_json + self.cog.previous_overwrites.get.return_value = overwrite_json prev_overwrite_dict = dict(self.overwrite) await self.cog._unsilence(self.channel) -- cgit v1.2.3 From 2fd2c77035e87dde009c39aa7345e4871d5b41df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 15:02:13 -0700 Subject: Silence: cancel init task when cog unloads --- bot/cogs/moderation/silence.py | 9 +++++++-- tests/bot/cogs/moderation/test_silence.py | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 5851be00a..c339fd4d0 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -231,8 +231,13 @@ class Silence(commands.Cog): self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() + """Cancel the init task and scheduled tasks.""" + # It's important to wait for _init_task (specifically for _reschedule) to be cancelled + # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule + # more tasks after cancel_all has finished, despite _init_task.cancel being called first. + # This is cause cancel() on its own doesn't block until the task is cancelled. + self._init_task.cancel() + self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index a66d27d08..d56a731b6 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -127,9 +127,12 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog._reschedule.assert_awaited_once_with() def test_cog_unload_cancelled_tasks(self): - """All scheduled tasks were cancelled.""" + """The init task was cancelled.""" + self.cog._init_task = asyncio.Future() self.cog.cog_unload() - self.cog.scheduler.cancel_all.assert_called_once_with() + + # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda. + self.assertTrue(self.cog._init_task.cancelled()) @autospec(silence, "with_role_check") @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 569f2aaf7b6025bddd59abb39d21939c4666ebed Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 22 Sep 2020 14:10:34 -0700 Subject: Silence: use f-string for message Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c339fd4d0..8e15b2284 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = Emojis.check_mark + " silenced current channel for {duration} minute(s)." +MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( -- cgit v1.2.3 From a96c5434a51a074584e46058591e4e27c91538f7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Sep 2020 14:05:12 -0700 Subject: Add license & copyright for autospec's _decoration_helper --- LICENSE-THIRD-PARTY | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 LICENSE-THIRD-PARTY diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..a126700a3 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,52 @@ +--------------------------------------------------------------------------------------------------- + PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +Applies to: + - Copyright © 2001-2020 Python Software Foundation. All rights reserved. + - tests/_autospec.py: _decoration_helper +--------------------------------------------------------------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. -- cgit v1.2.3 From 5381552c18b541121a33171f4763047e03362780 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 12:45:07 -0700 Subject: Silence: move unsilence scheduling to a separate function --- bot/cogs/moderation/silence.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 8e15b2284..08d0328ab 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -110,16 +110,12 @@ class Silence(commands.Cog): await ctx.send(MSG_SILENCE_FAIL) return + await self._schedule_unsilence(ctx, duration) + if duration is None: await ctx.send(MSG_SILENCE_PERMANENT) - await self.unsilence_timestamps.set(ctx.channel.id, -1) - return - - await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) - - self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) - await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + else: + await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -170,6 +166,15 @@ class Silence(commands.Cog): log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True + async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: + """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" + if duration is None: + await self.unsilence_timestamps.set(ctx.channel.id, -1) + else: + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) + await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + async def _unsilence(self, channel: TextChannel) -> bool: """ Unsilence `channel`. -- cgit v1.2.3 From cf2c03215ef340b9e093828de365563bb6be587a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 13:23:03 -0700 Subject: Silence: refactor _silence * Rename to `_silence_overwrites` * Reduce responsibilities to only setting permission overwrites * Log in `silence` instead * Add to notifier in `silence` instead --- bot/cogs/moderation/silence.py | 27 ++++++++-------------- tests/bot/cogs/moderation/test_silence.py | 38 ++++++++++++++++++------------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 08d0328ab..12896022f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -104,17 +104,23 @@ class Silence(commands.Cog): Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ await self._init_task - log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") - if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): + channel_info = f"#{ctx.channel} ({ctx.channel.id})" + log.debug(f"{ctx.author} is silencing channel {channel_info}.") + + if not await self._silence_overwrites(ctx.channel): + log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await ctx.send(MSG_SILENCE_FAIL) return await self._schedule_unsilence(ctx, duration) if duration is None: + log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: + self.notifier.add_channel(ctx.channel) + log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush",)) @@ -139,31 +145,18 @@ class Silence(commands.Cog): else: await channel.send(MSG_UNSILENCE_SUCCESS) - async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: - """ - Silence `channel` for `self._verified_role`. - - If `persistent` is `True` add `channel` to notifier. - `duration` is only used for logging; if None is passed `persistent` should be True to not log None. - Return `True` if channel permissions were changed, `False` otherwise. - """ + async def _silence_overwrites(self, channel: TextChannel) -> bool: + """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): - log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) - if persistent: - log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") - self.notifier.add_channel(channel) - return True - - log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d56a731b6..9dbdfd10a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -245,8 +245,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, message, was_silenced in test_cases: ctx = MockContext() - with self.subTest(was_silenced=was_silenced, message=message, duration=duration): - with mock.patch.object(self.cog, "_silence", return_value=was_silenced): + with mock.patch.object(self.cog, "_silence_overwrites", return_value=was_silenced): + with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) ctx.send.assert_called_once_with(message) @@ -264,12 +264,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() channel.overwrites_for.return_value = overwrite - self.assertFalse(await self.cog._silence(channel, True, None)) + self.assertFalse(await self.cog._silence_overwrites(channel)) channel.set_permissions.assert_not_called() async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._silence(self.channel, False, None)) + self.assertTrue(await self.cog._silence_overwrites(self.channel)) self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( @@ -280,7 +280,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) - await self.cog._silence(self.channel, False, None) + await self.cog._silence_overwrites(self.channel) new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. @@ -291,22 +291,28 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_added_removed_notifier(self): - """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=True): - await self.cog._silence(self.channel, True, None) - self.cog.notifier.add_channel.assert_called_once() + async def test_temp_added_to_notifier(self): + """Channel was added to notifier if a duration was set for the silence.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + await self.cog.silence.callback(self.cog, MockContext(), 15) + self.cog.notifier.add_channel.assert_called_once() - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=False): - await self.cog._silence(self.channel, False, None) - self.cog.notifier.add_channel.assert_not_called() + async def test_indefinite_not_added_to_notifier(self): + """Channel was not added to notifier if a duration was not set for the silence.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + await self.cog.silence.callback(self.cog, MockContext(), None) + self.cog.notifier.add_channel.assert_not_called() + + async def test_silenced_not_added_to_notifier(self): + """Channel was not added to the notifier if it was already silenced.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=False): + await self.cog.silence.callback(self.cog, MockContext(), 15) + self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._silence(self.channel, False, None) + await self.cog._silence_overwrites(self.channel) self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") -- cgit v1.2.3 From f218c7b0a505416d44b177b0e863575db626d20c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 13:27:42 -0700 Subject: Silence: rename _init_cog to _async_init --- bot/cogs/moderation/silence.py | 4 ++-- tests/bot/cogs/moderation/test_silence.py | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 12896022f..178dee06f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -82,9 +82,9 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._init_task = self.bot.loop.create_task(self._init_cog()) + self._init_task = self.bot.loop.create_task(self._async_init()) - async def _init_cog(self) -> None: + async def _async_init(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 9dbdfd10a..5588115ae 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -91,39 +91,39 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog = silence.Silence(self.bot) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_guild(self): + async def test_async_init_got_guild(self): """Bot got guild after it became available.""" - await self.cog._init_cog() + await self.cog._async_init() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_role(self): + async def test_async_init_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._init_cog() + await self.cog._async_init() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_channels(self): + async def test_async_init_got_channels(self): """Got channels from bot.""" - await self.cog._init_cog() + await self.cog._async_init() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @autospec(silence, "SilenceNotifier") - async def test_init_cog_got_notifier(self, notifier): + async def test_async_init_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._init_cog() + await self.cog._async_init() notifier.assert_called_once_with(self.cog._mod_log_channel) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_rescheduled(self): + async def test_async_init_rescheduled(self): """`_reschedule_` coroutine was awaited.""" self.cog._reschedule = mock.create_autospec(self.cog._reschedule) - await self.cog._init_cog() + await self.cog._async_init() self.cog._reschedule.assert_awaited_once_with() def test_cog_unload_cancelled_tasks(self): @@ -154,7 +154,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) with mock.patch.object(self.cog, "_reschedule", autospec=True): - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" @@ -230,7 +230,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) @@ -367,7 +367,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) self.cog.previous_overwrites = overwrites_cache - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.cog.scheduler.__contains__.return_value = True overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' -- cgit v1.2.3 From 46bdcdf9414786f1432b4937590a0448122e6f34 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 7 Oct 2020 15:13:01 -0700 Subject: Silence tests: fix unawaited coro warnings Because the Scheduler is mocked, it doesn't actually do anything with the coroutines passed to the schedule() functions, hence the warnings. --- tests/bot/cogs/moderation/test_silence.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5588115ae..6a8db72e8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -68,7 +68,9 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): with self.subTest(current_loop=current_loop): with mock.patch.object(self.notifier, "_current_loop", new=current_loop): await self.notifier._notifier() - self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.assert_called_once_with( + f"<@&{Roles.moderators}> currently silenced channels: " + ) self.alert_channel.send.reset_mock() async def test_notifier_skips_alert(self): @@ -158,7 +160,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 10000000000)] self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -230,6 +232,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) + # Avoid unawaited coroutine warnings. + self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close() + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.channel = MockTextChannel() -- cgit v1.2.3 From d0c3990e8eb9e68537c05ec58594abdf5c4cee9e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 12:25:41 -0700 Subject: Silence: add to notifier when indefinite rather than temporary Accidentally swapped the logic in a previous commit during a refactor. --- bot/cogs/moderation/silence.py | 2 +- tests/bot/cogs/moderation/test_silence.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 178dee06f..95706392a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -116,10 +116,10 @@ class Silence(commands.Cog): await self._schedule_unsilence(ctx, duration) if duration is None: + self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: - self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6a8db72e8..50d8419ac 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -296,17 +296,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_temp_added_to_notifier(self): - """Channel was added to notifier if a duration was set for the silence.""" + async def test_temp_not_added_to_notifier(self): + """Channel was not added to notifier if a duration was set for the silence.""" with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), 15) - self.cog.notifier.add_channel.assert_called_once() + self.cog.notifier.add_channel.assert_not_called() - async def test_indefinite_not_added_to_notifier(self): - """Channel was not added to notifier if a duration was not set for the silence.""" + async def test_indefinite_added_to_notifier(self): + """Channel was added to notifier if a duration was not set for the silence.""" with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), None) - self.cog.notifier.add_channel.assert_not_called() + self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" -- cgit v1.2.3 From 5b87a272ff21df9fa4fb59fdf9ec92c6b57193c6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 13:22:54 -0700 Subject: Silence: remove _mod_log_channel attribute It's only used as an argument to `SilenceNotifier`, so it doesn't need to be an instance attribute. --- bot/cogs/moderation/silence.py | 3 +-- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 95706392a..80c4e6a25 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -91,8 +91,7 @@ class Silence(commands.Cog): guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) - self._mod_log_channel = self.bot.get_channel(Channels.mod_log) - self.notifier = SilenceNotifier(self._mod_log_channel) + self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() @commands.command(aliases=("hush",)) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 50d8419ac..6f8f4386b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -119,7 +119,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) await self.cog._async_init() - notifier.assert_called_once_with(self.cog._mod_log_channel) + notifier.assert_called_once_with(mod_log) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_rescheduled(self): -- cgit v1.2.3 From e85a4d254cadd303537a4d2cce6637bbcd3cf2f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 13:23:35 -0700 Subject: Silence tests: make _async_init attribute tests more robust --- tests/bot/cogs/moderation/test_silence.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f8f4386b..3e1b963b0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -102,24 +102,28 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._async_init() guild = self.bot.get_guild() - guild.get_role.assert_called_once_with(Roles.verified) + guild.get_role.side_effect = lambda id_: Mock(id=id_) + + await self.cog._async_init() + self.assertEqual(self.cog._verified_role.id, Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_channels(self): """Got channels from bot.""" + self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + await self.cog._async_init() - self.bot.get_channel.called_once_with(Channels.mod_alerts) - self.bot.get_channel.called_once_with(Channels.mod_log) + self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts) @autospec(silence, "SilenceNotifier") async def test_async_init_got_notifier(self, notifier): """Notifier was started with channel.""" - mod_log = MockTextChannel() - self.bot.get_channel.side_effect = (None, mod_log) + self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + await self.cog._async_init() - notifier.assert_called_once_with(mod_log) + notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log)) + self.assertEqual(self.cog.notifier, notifier.return_value) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_rescheduled(self): -- cgit v1.2.3 From 59795ad20ff8a48cb1773ad02135a3da2d6e5eb9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 13:39:27 -0700 Subject: Silence: fix scheduled tasks not being cancelled on unload --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 80c4e6a25..c54f9d849 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -234,7 +234,7 @@ class Silence(commands.Cog): # more tasks after cancel_all has finished, despite _init_task.cancel being called first. # This is cause cancel() on its own doesn't block until the task is cancelled. self._init_task.cancel() - self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all) + self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all()) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From bfab4928e5b219660f76e2516c4ec0bb67fcba89 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 18:26:03 -0700 Subject: Silence: require only 1 permission to be False for a manual unsilence Previously, both sending messages and adding reactions had to be false in order for the manual unsilence failure message to be sent. Because staff may only set one of these manually, the message should be sent if at least one of the permissions is set. --- bot/exts/moderation/silence.py | 2 +- tests/bot/exts/moderation/test_silence.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index bb8e06924..ee2c0dc7c 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -136,7 +136,7 @@ class Silence(commands.Cog): """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) - if overwrite.send_messages is False and overwrite.add_reactions is False: + if overwrite.send_messages is False or overwrite.add_reactions is False: await channel.send(MSG_UNSILENCE_MANUAL) else: await channel.send(MSG_UNSILENCE_FAIL) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 39e32fdb2..6d5ffa7e8 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -411,6 +411,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), + (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() -- cgit v1.2.3 From e1b7b48db3a1510dd2defd9879c12b85929f7364 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 18:31:13 -0700 Subject: Silence: amend the manual unsilence message Clarify that this situation could also result from the cache being cleared prematurely. There's no way to distinguish the two scenarios, so a manual unsilence is required for both. --- bot/exts/moderation/silence.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index ee2c0dc7c..cfdefe103 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -22,8 +22,9 @@ MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{durat MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the current " - f"overwrites were set manually. Please edit them manually to unsilence." + f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"set manually or the cache was prematurely cleared. " + f"Please edit the overwrites manually to unsilence." ) MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." -- cgit v1.2.3 From dc2797bc7d24d80c08c559bc381c465db1312143 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:12:29 -0700 Subject: Silence: rename function to reduce ambiguity --- bot/exts/moderation/silence.py | 4 ++-- tests/bot/exts/moderation/test_silence.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index cfdefe103..3bbf8d21a 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -107,7 +107,7 @@ class Silence(commands.Cog): channel_info = f"#{ctx.channel} ({ctx.channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._silence_overwrites(ctx.channel): + if not await self._set_silence_overwrites(ctx.channel): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await ctx.send(MSG_SILENCE_FAIL) return @@ -144,7 +144,7 @@ class Silence(commands.Cog): else: await channel.send(MSG_UNSILENCE_SUCCESS) - async def _silence_overwrites(self, channel: TextChannel) -> bool: + async def _set_silence_overwrites(self, channel: TextChannel) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6d5ffa7e8..6b67a21a0 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -274,7 +274,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, message, was_silenced in test_cases: ctx = MockContext() - with mock.patch.object(self.cog, "_silence_overwrites", return_value=was_silenced): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) ctx.send.assert_called_once_with(message) @@ -293,12 +293,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() channel.overwrites_for.return_value = overwrite - self.assertFalse(await self.cog._silence_overwrites(channel)) + self.assertFalse(await self.cog._set_silence_overwrites(channel)) channel.set_permissions.assert_not_called() async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._silence_overwrites(self.channel)) + self.assertTrue(await self.cog._set_silence_overwrites(self.channel)) self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( @@ -309,7 +309,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) - await self.cog._silence_overwrites(self.channel) + await self.cog._set_silence_overwrites(self.channel) new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. @@ -322,26 +322,26 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_temp_not_added_to_notifier(self): """Channel was not added to notifier if a duration was set for the silence.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_indefinite_added_to_notifier(self): """Channel was added to notifier if a duration was not set for the silence.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), None) self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=False): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._silence_overwrites(self.channel) + await self.cog._set_silence_overwrites(self.channel) self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") -- cgit v1.2.3 From a03fe441e1082c93d268a7e6673ae9f0b3feba34 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:31:30 -0700 Subject: Silence: add locks to commands --- bot/exts/moderation/silence.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 3bbf8d21a..e6712b3b6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -2,6 +2,7 @@ import json import logging from contextlib import suppress from datetime import datetime, timedelta, timezone +from operator import attrgetter from typing import Optional from async_rediscache import RedisCache @@ -12,10 +13,13 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils.lock import LockedResourceError, lock_arg from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +LOCK_NAMESPACE = "silence" + MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." @@ -95,6 +99,7 @@ class Silence(commands.Cog): await self._reschedule() @commands.command(aliases=("hush",)) + @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -133,6 +138,7 @@ class Silence(commands.Cog): log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) + @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) async def _unsilence_wrapper(self, channel: TextChannel) -> None: """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): @@ -222,7 +228,9 @@ class Silence(commands.Cog): dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() if delta <= 0: - await self._unsilence_wrapper(channel) + # Suppress the error since it's not being invoked by a user via the command. + with suppress(LockedResourceError): + await self._unsilence_wrapper(channel) else: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) -- cgit v1.2.3 From 75efbf81cf403d1f03f9d3147a6493f08081f55b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:32:32 -0700 Subject: Reminders: rename namespace constant It's better to have a self-documenting name than a comment, which, by the way, was using the old name for the decorator. --- bot/exts/utils/reminders.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index bf4e24661..3113a1149 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) -NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos +LOCK_NAMESPACE = "reminder" WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -170,7 +170,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -378,7 +378,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @lock_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" if not await self._can_modify(ctx, id_): @@ -398,7 +398,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @lock_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" if not await self._can_modify(ctx, id_): -- cgit v1.2.3 From 50324b3ec0e0285300e4f4cf389fd93c4801f1ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 21 Oct 2020 09:46:33 -0700 Subject: Silence tests: update docstrings in notifier tests --- tests/bot/exts/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6b67a21a0..104293d8e 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -43,7 +43,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.start = self.notifier_start_mock = Mock() def test_add_channel_adds_channel(self): - """Channel in FirstHash with current loop is added to internal set.""" + """Channel is added to `_silenced_channels` with the current loop.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) @@ -61,7 +61,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier_start_mock.assert_not_called() def test_remove_channel_removes_channel(self): - """Channel in FirstHash is removed from `_silenced_channels`.""" + """Channel is removed from `_silenced_channels`.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.remove_channel(channel) -- cgit v1.2.3