diff options
| author | 2020-09-07 15:49:31 -0700 | |
|---|---|---|
| committer | 2020-09-07 15:53:10 -0700 | |
| commit | 7b311196613e358557a14aa75f01e8a54ab3e698 (patch) | |
| tree | 5b9e17c5e87edd132f65a38dd8ba4ebdab9c4bee | |
| parent | Merge pull request #1144 from Numerlor/text-link-fix (diff) | |
Sync: remove confirmation
The confirmation was intended to be a safe guard against cache issues
that would cause a huge number of roles/users to deleted after syncing.
With `wait_until_guild_available`, such cache issue shouldn't arise.
Therefore, this feature is obsolete.
Resolve #1075
| -rw-r--r-- | bot/cogs/sync/syncers.py | 170 | ||||
| -rw-r--r-- | bot/constants.py | 7 | ||||
| -rw-r--r-- | config-default.yml | 4 | ||||
| -rw-r--r-- | tests/bot/cogs/sync/test_base.py | 357 | 
4 files changed, 20 insertions, 518 deletions
| diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index f7ba811bc..b3819a1e1 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,15 +1,11 @@  import abc -import asyncio  import logging  import typing as t  from collections import namedtuple -from functools import partial -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord import Guild  from discord.ext.commands import Context -from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot @@ -25,9 +21,6 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))  class Syncer(abc.ABC):      """Base class for synchronising the database with objects in the Discord cache.""" -    _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " -    _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) -      def __init__(self, bot: Bot) -> None:          self.bot = bot @@ -37,112 +30,6 @@ class Syncer(abc.ABC):          """The name of the syncer; used in output messages and logging."""          raise NotImplementedError  # pragma: no cover -    async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: -        """ -        Send a prompt to confirm or abort a sync using reactions and return the sent message. - -        If a message is given, it is edited to display the prompt and reactions. Otherwise, a new -        message is sent to the dev-core channel and mentions the core developers role. If the -        channel cannot be retrieved, return None. -        """ -        log.trace(f"Sending {self.name} sync confirmation prompt.") - -        msg_content = ( -            f'Possible cache issue while syncing {self.name}s. ' -            f'More than {constants.Sync.max_diff} {self.name}s were changed. ' -            f'React to confirm or abort the sync.' -        ) - -        # Send to core developers if it's an automatic sync. -        if not message: -            log.trace("Message not provided for confirmation; creating a new one in dev-core.") -            channel = self.bot.get_channel(constants.Channels.dev_core) - -            if not channel: -                log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") -                try: -                    channel = await self.bot.fetch_channel(constants.Channels.dev_core) -                except HTTPException: -                    log.exception( -                        f"Failed to fetch channel for sending sync confirmation prompt; " -                        f"aborting {self.name} sync." -                    ) -                    return None - -            allowed_roles = [discord.Object(constants.Roles.core_developers)] -            message = await channel.send( -                f"{self._CORE_DEV_MENTION}{msg_content}", -                allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) -            ) -        else: -            await message.edit(content=msg_content) - -        # Add the initial reactions. -        log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") -        for emoji in self._REACTION_EMOJIS: -            await message.add_reaction(emoji) - -        return message - -    def _reaction_check( -        self, -        author: Member, -        message: Message, -        reaction: Reaction, -        user: t.Union[Member, User] -    ) -> bool: -        """ -        Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - -        If the `author` of the prompt is a bot, then a reaction by any core developer will be -        considered valid. Otherwise, the author of the reaction (`user`) will have to be the -        `author` of the prompt. -        """ -        # For automatic syncs, check for the core dev role instead of an exact author -        has_role = any(constants.Roles.core_developers == role.id for role in user.roles) -        return ( -            reaction.message.id == message.id -            and not user.bot -            and (has_role if author.bot else user == author) -            and str(reaction.emoji) in self._REACTION_EMOJIS -        ) - -    async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: -        """ -        Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - -        Uses the `_reaction_check` function to determine if a reaction is valid. - -        If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. -        To acknowledge the reaction (or lack thereof), `message` will be edited. -        """ -        # Preserve the core-dev role mention in the message edits so users aren't confused about -        # where notifications came from. -        mention = self._CORE_DEV_MENTION if author.bot else "" - -        reaction = None -        try: -            log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") -            reaction, _ = await self.bot.wait_for( -                'reaction_add', -                check=partial(self._reaction_check, author, message), -                timeout=constants.Sync.confirm_timeout -            ) -        except asyncio.TimeoutError: -            # reaction will remain none thus sync will be aborted in the finally block below. -            log.debug(f"The {self.name} syncer confirmation prompt timed out.") - -        if str(reaction) == constants.Emojis.check_mark: -            log.trace(f"The {self.name} syncer was confirmed.") -            await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') -            return True -        else: -            log.info(f"The {self.name} syncer was aborted or timed out!") -            await message.edit( -                content=f':warning: {mention}{self.name} sync aborted or timed out!' -            ) -            return False -      @abc.abstractmethod      async def _get_diff(self, guild: Guild) -> _Diff:          """Return the difference between the cache of `guild` and the database.""" @@ -153,34 +40,6 @@ class Syncer(abc.ABC):          """Perform the API calls for synchronisation."""          raise NotImplementedError  # pragma: no cover -    async def _get_confirmation_result( -        self, -        diff_size: int, -        author: Member, -        message: t.Optional[Message] = None -    ) -> t.Tuple[bool, t.Optional[Message]]: -        """ -        Prompt for confirmation and return a tuple of the result and the prompt message. - -        `diff_size` is the size of the diff of the sync. If it is greater than -        `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the -        sync and the `message` is an extant message to edit to display the prompt. - -        If confirmed or no confirmation was needed, the result is True. The returned message will -        either be the given `message` or a new one which was created when sending the prompt. -        """ -        log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") -        if diff_size > constants.Sync.max_diff: -            message = await self._send_prompt(message) -            if not message: -                return False, None  # Couldn't get channel. - -            confirmed = await self._wait_for_confirmation(author, message) -            if not confirmed: -                return False, message  # Sync aborted. - -        return True, message -      async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:          """          Synchronise the database with the cache of `guild`. @@ -191,24 +50,8 @@ class Syncer(abc.ABC):          """          log.info(f"Starting {self.name} syncer.") -        message = None -        author = self.bot.user -        if ctx: -            message = await ctx.send(f"📊 Synchronising {self.name}s.") -            author = ctx.author - +        message = await ctx.send(f"📊 Synchronising {self.name}s.") if ctx else None          diff = await self._get_diff(guild) -        diff_dict = diff._asdict()  # Ugly method for transforming the NamedTuple into a dict -        totals = {k: len(v) for k, v in diff_dict.items() if v is not None} -        diff_size = sum(totals.values()) - -        confirmed, message = await self._get_confirmation_result(diff_size, author, message) -        if not confirmed: -            return - -        # Preserve the core-dev role mention in the message edits so users aren't confused about -        # where notifications came from. -        mention = self._CORE_DEV_MENTION if author.bot else ""          try:              await self._sync(diff) @@ -217,11 +60,14 @@ class Syncer(abc.ABC):              # Don't show response text because it's probably some really long HTML.              results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" -            content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" +            content = f":x: Synchronisation of {self.name}s failed: {results}"          else: -            results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) +            diff_dict = diff._asdict()  # Ugly method for transforming the NamedTuple into a dict +            results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) +            results = ", ".join(results) +              log.info(f"{self.name} syncer finished: {results}.") -            content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" +            content = f":ok_hand: Synchronisation of {self.name}s complete: {results}"          if message:              await message.edit(content=content) diff --git a/bot/constants.py b/bot/constants.py index 17fe34e95..3129354d3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -563,13 +563,6 @@ class RedirectOutput(metaclass=YAMLGetter):      delete_delay: int -class Sync(metaclass=YAMLGetter): -    section = 'sync' - -    confirm_timeout: int -    max_diff: int - -  class PythonNews(metaclass=YAMLGetter):      section = 'python_news' diff --git a/config-default.yml b/config-default.yml index 6e7cff92d..d48739002 100644 --- a/config-default.yml +++ b/config-default.yml @@ -460,10 +460,6 @@ redirect_output:      delete_invocation: true      delete_delay: 15 -sync: -    confirm_timeout: 300 -    max_diff: 10 -  duck_pond:      threshold: 5      custom_emojis: diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 70aea2bab..c3456f724 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,12 +1,9 @@ -import asyncio  import unittest  from unittest import mock -import discord -from bot import constants  from bot.api import ResponseCodeError -from bot.cogs.sync.syncers import Syncer, _Diff +from bot.cogs.sync.syncers import Syncer  from tests import helpers @@ -30,280 +27,16 @@ class SyncerBaseTests(unittest.TestCase):              Syncer(self.bot) -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): -    """Tests for sending the sync confirmation prompt.""" - -    def setUp(self): -        self.bot = helpers.MockBot() -        self.syncer = TestSyncer(self.bot) - -    def mock_get_channel(self): -        """Fixture to return a mock channel and message for when `get_channel` is used.""" -        self.bot.reset_mock() - -        mock_channel = helpers.MockTextChannel() -        mock_message = helpers.MockMessage() - -        mock_channel.send.return_value = mock_message -        self.bot.get_channel.return_value = mock_channel - -        return mock_channel, mock_message - -    def mock_fetch_channel(self): -        """Fixture to return a mock channel and message for when `fetch_channel` is used.""" -        self.bot.reset_mock() - -        mock_channel = helpers.MockTextChannel() -        mock_message = helpers.MockMessage() - -        self.bot.get_channel.return_value = None -        mock_channel.send.return_value = mock_message -        self.bot.fetch_channel.return_value = mock_channel - -        return mock_channel, mock_message - -    async def test_send_prompt_edits_and_returns_message(self): -        """The given message should be edited to display the prompt and then should be returned.""" -        msg = helpers.MockMessage() -        ret_val = await self.syncer._send_prompt(msg) - -        msg.edit.assert_called_once() -        self.assertIn("content", msg.edit.call_args[1]) -        self.assertEqual(ret_val, msg) - -    async def test_send_prompt_gets_dev_core_channel(self): -        """The dev-core channel should be retrieved if an extant message isn't given.""" -        subtests = ( -            (self.bot.get_channel, self.mock_get_channel), -            (self.bot.fetch_channel, self.mock_fetch_channel), -        ) - -        for method, mock_ in subtests: -            with self.subTest(method=method, msg=mock_.__name__): -                mock_() -                await self.syncer._send_prompt() - -                method.assert_called_once_with(constants.Channels.dev_core) - -    async def test_send_prompt_returns_none_if_channel_fetch_fails(self): -        """None should be returned if there's an HTTPException when fetching the channel.""" -        self.bot.get_channel.return_value = None -        self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - -        ret_val = await self.syncer._send_prompt() - -        self.assertIsNone(ret_val) - -    async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): -        """A new message mentioning core devs should be sent and returned if message isn't given.""" -        for mock_ in (self.mock_get_channel, self.mock_fetch_channel): -            with self.subTest(msg=mock_.__name__): -                mock_channel, mock_message = mock_() -                ret_val = await self.syncer._send_prompt() - -                mock_channel.send.assert_called_once() -                self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) -                self.assertEqual(ret_val, mock_message) - -    async def test_send_prompt_adds_reactions(self): -        """The message should have reactions for confirmation added.""" -        extant_message = helpers.MockMessage() -        subtests = ( -            (extant_message, lambda: (None, extant_message)), -            (None, self.mock_get_channel), -            (None, self.mock_fetch_channel), -        ) - -        for message_arg, mock_ in subtests: -            subtest_msg = "Extant message" if mock_.__name__ == "<lambda>" else mock_.__name__ - -            with self.subTest(msg=subtest_msg): -                _, mock_message = mock_() -                await self.syncer._send_prompt(message_arg) - -                calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] -                mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): -    """Tests for waiting for a sync confirmation reaction on the prompt.""" - -    def setUp(self): -        self.bot = helpers.MockBot() -        self.syncer = TestSyncer(self.bot) -        self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - -    @staticmethod -    def get_message_reaction(emoji): -        """Fixture to return a mock message an reaction from the given `emoji`.""" -        message = helpers.MockMessage() -        reaction = helpers.MockReaction(emoji=emoji, message=message) - -        return message, reaction - -    def test_reaction_check_for_valid_emoji_and_authors(self): -        """Should return True if authors are identical or are a bot and a core dev, respectively.""" -        user_subtests = ( -            ( -                helpers.MockMember(id=77), -                helpers.MockMember(id=77), -                "identical users", -            ), -            ( -                helpers.MockMember(id=77, bot=True), -                helpers.MockMember(id=43, roles=[self.core_dev_role]), -                "bot author and core-dev reactor", -            ), -        ) - -        for emoji in self.syncer._REACTION_EMOJIS: -            for author, user, msg in user_subtests: -                with self.subTest(author=author, user=user, emoji=emoji, msg=msg): -                    message, reaction = self.get_message_reaction(emoji) -                    ret_val = self.syncer._reaction_check(author, message, reaction, user) - -                    self.assertTrue(ret_val) - -    def test_reaction_check_for_invalid_reactions(self): -        """Should return False for invalid reaction events.""" -        valid_emoji = self.syncer._REACTION_EMOJIS[0] -        subtests = ( -            ( -                helpers.MockMember(id=77), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=43, roles=[self.core_dev_role]), -                "users are not identical", -            ), -            ( -                helpers.MockMember(id=77, bot=True), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=43), -                "reactor lacks the core-dev role", -            ), -            ( -                helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), -                "reactor is a bot", -            ), -            ( -                helpers.MockMember(id=77), -                helpers.MockMessage(id=95), -                helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), -                helpers.MockMember(id=77), -                "messages are not identical", -            ), -            ( -                helpers.MockMember(id=77), -                *self.get_message_reaction("InVaLiD"), -                helpers.MockMember(id=77), -                "emoji is invalid", -            ), -        ) - -        for *args, msg in subtests: -            kwargs = dict(zip(("author", "message", "reaction", "user"), args)) -            with self.subTest(**kwargs, msg=msg): -                ret_val = self.syncer._reaction_check(*args) -                self.assertFalse(ret_val) - -    async def test_wait_for_confirmation(self): -        """The message should always be edited and only return True if the emoji is a check mark.""" -        subtests = ( -            (constants.Emojis.check_mark, True, None), -            ("InVaLiD", False, None), -            (None, False, asyncio.TimeoutError), -        ) - -        for emoji, ret_val, side_effect in subtests: -            for bot in (True, False): -                with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): -                    # Set up mocks -                    message = helpers.MockMessage() -                    member = helpers.MockMember(bot=bot) - -                    self.bot.wait_for.reset_mock() -                    self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) -                    self.bot.wait_for.side_effect = side_effect - -                    # Call the function -                    actual_return = await self.syncer._wait_for_confirmation(member, message) - -                    # Perform assertions -                    self.bot.wait_for.assert_called_once() -                    self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - -                    message.edit.assert_called_once() -                    kwargs = message.edit.call_args[1] -                    self.assertIn("content", kwargs) - -                    # Core devs should only be mentioned if the author is a bot. -                    if bot: -                        self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) -                    else: -                        self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - -                    self.assertIs(actual_return, ret_val) - -  class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):      """Tests for main function orchestrating the sync."""      def setUp(self):          self.bot = helpers.MockBot(user=helpers.MockMember(bot=True))          self.syncer = TestSyncer(self.bot) +        self.guild = helpers.MockGuild() -    async def test_sync_respects_confirmation_result(self): -        """The sync should abort if confirmation fails and continue if confirmed.""" -        mock_message = helpers.MockMessage() -        subtests = ( -            (True, mock_message), -            (False, None), -        ) - -        for confirmed, message in subtests: -            with self.subTest(confirmed=confirmed): -                self.syncer._sync.reset_mock() -                self.syncer._get_diff.reset_mock() - -                diff = _Diff({1, 2, 3}, {4, 5}, None) -                self.syncer._get_diff.return_value = diff -                self.syncer._get_confirmation_result = mock.AsyncMock( -                    return_value=(confirmed, message) -                ) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) - -                self.syncer._get_diff.assert_called_once_with(guild) -                self.syncer._get_confirmation_result.assert_called_once() - -                if confirmed: -                    self.syncer._sync.assert_called_once_with(diff) -                else: -                    self.syncer._sync.assert_not_called() - -    async def test_sync_diff_size(self): -        """The diff size should be correctly calculated.""" -        subtests = ( -            (6, _Diff({1, 2}, {3, 4}, {5, 6})), -            (5, _Diff({1, 2, 3}, None, {4, 5})), -            (0, _Diff(None, None, None)), -            (0, _Diff(set(), set(), set())), -        ) - -        for size, diff in subtests: -            with self.subTest(size=size, diff=diff): -                self.syncer._get_diff.reset_mock() -                self.syncer._get_diff.return_value = diff -                self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) - -                self.syncer._get_diff.assert_called_once_with(guild) -                self.syncer._get_confirmation_result.assert_called_once() -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) +        # Make sure `_get_diff` returns a MagicMock, not an AsyncMock +        self.syncer._get_diff.return_value = mock.MagicMock()      async def test_sync_message_edited(self):          """The message should be edited if one was sent, even if the sync has an API error.""" @@ -316,89 +49,23 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):          for message, side_effect, should_edit in subtests:              with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit):                  self.syncer._sync.side_effect = side_effect -                self.syncer._get_confirmation_result = mock.AsyncMock( -                    return_value=(True, message) -                ) -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) +                await self.syncer.sync(self.guild)                  if should_edit:                      message.edit.assert_called_once()                      self.assertIn("content", message.edit.call_args[1]) -    async def test_sync_confirmation_context_redirect(self): -        """If ctx is given, a new message should be sent and author should be ctx's author.""" -        mock_member = helpers.MockMember() +    async def test_sync_message_sent(self): +        """If ctx is given, a new message should be sent."""          subtests = ( -            (None, self.bot.user, None), -            (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), +            (None, None), +            (helpers.MockContext(), helpers.MockMessage()),          ) -        for ctx, author, message in subtests: -            with self.subTest(ctx=ctx, author=author, message=message): -                if ctx is not None: -                    ctx.send.return_value = message - -                # Make sure `_get_diff` returns a MagicMock, not an AsyncMock -                self.syncer._get_diff.return_value = mock.MagicMock() - -                self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild, ctx) +        for ctx, message in subtests: +            with self.subTest(ctx=ctx, message=message): +                await self.syncer.sync(self.guild, ctx)                  if ctx is not None:                      ctx.send.assert_called_once() - -                self.syncer._get_confirmation_result.assert_called_once() -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - -    @mock.patch.object(constants.Sync, "max_diff", new=3) -    async def test_confirmation_result_small_diff(self): -        """Should always return True and the given message if the diff size is too small.""" -        author = helpers.MockMember() -        expected_message = helpers.MockMessage() - -        for size in (3, 2):  # pragma: no cover -            with self.subTest(size=size): -                self.syncer._send_prompt = mock.AsyncMock() -                self.syncer._wait_for_confirmation = mock.AsyncMock() - -                coro = self.syncer._get_confirmation_result(size, author, expected_message) -                result, actual_message = await coro - -                self.assertTrue(result) -                self.assertEqual(actual_message, expected_message) -                self.syncer._send_prompt.assert_not_called() -                self.syncer._wait_for_confirmation.assert_not_called() - -    @mock.patch.object(constants.Sync, "max_diff", new=3) -    async def test_confirmation_result_large_diff(self): -        """Should return True if confirmed and False if _send_prompt fails or aborted.""" -        author = helpers.MockMember() -        mock_message = helpers.MockMessage() - -        subtests = ( -            (True, mock_message, True, "confirmed"), -            (False, None, False, "_send_prompt failed"), -            (False, mock_message, False, "aborted"), -        ) - -        for expected_result, expected_message, confirmed, msg in subtests:  # pragma: no cover -            with self.subTest(msg=msg): -                self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) -                self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - -                coro = self.syncer._get_confirmation_result(4, author) -                actual_result, actual_message = await coro - -                self.syncer._send_prompt.assert_called_once_with(None)  # message defaults to None -                self.assertIs(actual_result, expected_result) -                self.assertEqual(actual_message, expected_message) - -                if expected_message: -                    self.syncer._wait_for_confirmation.assert_called_once_with( -                        author, expected_message -                    ) | 
