/ tests / gateway / test_discord_connect.py
test_discord_connect.py
  1  import asyncio
  2  import sys
  3  from types import SimpleNamespace
  4  from unittest.mock import AsyncMock, MagicMock
  5  
  6  import pytest
  7  
  8  from gateway.config import PlatformConfig
  9  
 10  
 11  class _FakeAllowedMentions:
 12      """Stand-in for ``discord.AllowedMentions`` — exposes the same four
 13      boolean flags as real attributes so tests can assert on safe defaults.
 14      """
 15  
 16      def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True):
 17          self.everyone = everyone
 18          self.roles = roles
 19          self.users = users
 20          self.replied_user = replied_user
 21  
 22  
 23  def _ensure_discord_mock():
 24      """Install (or augment) a mock ``discord`` module.
 25  
 26      Always force ``AllowedMentions`` onto whatever is in ``sys.modules`` —
 27      other test files also stub the module via ``setdefault``, and we need
 28      ``_build_allowed_mentions()``'s return value to have real attribute
 29      access regardless of which file loaded first.
 30      """
 31      if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
 32          sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
 33          return
 34  
 35      if sys.modules.get("discord") is None:
 36          discord_mod = MagicMock()
 37          discord_mod.Intents.default.return_value = MagicMock()
 38          discord_mod.Client = MagicMock
 39          discord_mod.File = MagicMock
 40          discord_mod.DMChannel = type("DMChannel", (), {})
 41          discord_mod.Thread = type("Thread", (), {})
 42          discord_mod.ForumChannel = type("ForumChannel", (), {})
 43          discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
 44          discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
 45          discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
 46          discord_mod.Interaction = object
 47          discord_mod.Embed = MagicMock
 48          discord_mod.app_commands = SimpleNamespace(
 49              describe=lambda **kwargs: (lambda fn: fn),
 50              choices=lambda **kwargs: (lambda fn: fn),
 51              Choice=lambda **kwargs: SimpleNamespace(**kwargs),
 52          )
 53          discord_mod.opus = SimpleNamespace(is_loaded=lambda: True)
 54  
 55          ext_mod = MagicMock()
 56          commands_mod = MagicMock()
 57          commands_mod.Bot = MagicMock
 58          ext_mod.commands = commands_mod
 59  
 60          sys.modules["discord"] = discord_mod
 61          sys.modules.setdefault("discord.ext", ext_mod)
 62          sys.modules.setdefault("discord.ext.commands", commands_mod)
 63  
 64      sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
 65  
 66  
 67  _ensure_discord_mock()
 68  
 69  import gateway.platforms.discord as discord_platform  # noqa: E402
 70  from gateway.platforms.discord import DiscordAdapter  # noqa: E402
 71  
 72  
 73  class FakeTree:
 74      def __init__(self):
 75          self.sync = AsyncMock(return_value=[])
 76          self.fetch_commands = AsyncMock(return_value=[])
 77          self._commands = []
 78  
 79      def command(self, *args, **kwargs):
 80          return lambda fn: fn
 81  
 82      def get_commands(self, *args, **kwargs):
 83          return list(self._commands)
 84  
 85  
 86  class FakeBot:
 87      def __init__(self, *, intents, proxy=None, allowed_mentions=None, **_):
 88          self.intents = intents
 89          self.allowed_mentions = allowed_mentions
 90          self.application_id = 999
 91          self.user = SimpleNamespace(id=999, name="Hermes")
 92          self._events = {}
 93          self.tree = FakeTree()
 94          self.http = SimpleNamespace(
 95              upsert_global_command=AsyncMock(),
 96              edit_global_command=AsyncMock(),
 97              delete_global_command=AsyncMock(),
 98          )
 99  
100      def event(self, fn):
101          self._events[fn.__name__] = fn
102          return fn
103  
104      async def start(self, token):
105          if "on_ready" in self._events:
106              await self._events["on_ready"]()
107  
108      async def close(self):
109          return None
110  
111  
112  class SlowSyncTree(FakeTree):
113      def __init__(self):
114          super().__init__()
115          self.started = asyncio.Event()
116          self.allow_finish = asyncio.Event()
117  
118          async def _slow_sync():
119              self.started.set()
120              await self.allow_finish.wait()
121              return []
122  
123          self.sync = AsyncMock(side_effect=_slow_sync)
124  
125  
126  class SlowSyncBot(FakeBot):
127      def __init__(self, *, intents, proxy=None):
128          super().__init__(intents=intents, proxy=proxy)
129          self.tree = SlowSyncTree()
130  
131  
132  @pytest.mark.asyncio
133  @pytest.mark.parametrize(
134      ("allowed_users", "expected_members_intent"),
135      [
136          ("769524422783664158", False),
137          ("abhey-gupta", True),
138          ("769524422783664158,abhey-gupta", True),
139      ],
140  )
141  async def test_connect_only_requests_members_intent_when_needed(monkeypatch, allowed_users, expected_members_intent):
142      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
143  
144      monkeypatch.setenv("DISCORD_ALLOWED_USERS", allowed_users)
145      monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
146      monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
147  
148      intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False)
149      monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
150  
151      created = {}
152  
153      def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
154          created["bot"] = FakeBot(intents=intents, allowed_mentions=allowed_mentions)
155          return created["bot"]
156  
157      monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
158      monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
159  
160      ok = await adapter.connect()
161  
162      assert ok is True
163      assert created["bot"].intents.members is expected_members_intent
164      # Safe-default AllowedMentions must be applied on every connect so the
165      # bot cannot @everyone from LLM output.  Granular overrides live in the
166      # dedicated test_discord_allowed_mentions.py module.
167      am = created["bot"].allowed_mentions
168      assert am is not None, "connect() must pass an AllowedMentions to commands.Bot"
169      assert am.everyone is False
170      assert am.roles is False
171  
172      await adapter.disconnect()
173  
174  
175  @pytest.mark.asyncio
176  async def test_reconnect_closes_previous_client_to_prevent_zombie_websocket(monkeypatch):
177      """Regression for #18187: calling connect() twice without disconnect() in
178      between (e.g. during an in-process reconnect attempt) must close the old
179      commands.Bot before creating a new one. Without this guard, two websockets
180      stay alive and both fire on_message, producing double responses with
181      different wording.
182      """
183      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
184  
185      monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
186      monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
187  
188      intents = SimpleNamespace(
189          message_content=False, dm_messages=False, guild_messages=False,
190          members=False, voice_states=False,
191      )
192      monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
193  
194      class TrackedBot(FakeBot):
195          """FakeBot that records close() calls and reports open/closed state."""
196          _closed = False
197  
198          def is_closed(self):
199              return self._closed
200  
201          async def close(self):
202              self._closed = True
203  
204      created: list[TrackedBot] = []
205  
206      def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
207          bot = TrackedBot(intents=intents, allowed_mentions=allowed_mentions)
208          created.append(bot)
209          return bot
210  
211      monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
212      monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
213  
214      # First connect — fresh adapter, no prior client.
215      assert await adapter.connect() is True
216      assert len(created) == 1
217      first_bot = created[0]
218      assert first_bot._closed is False, "first bot should still be open after connect()"
219  
220      # Second connect WITHOUT disconnect — simulates an in-process reconnect.
221      # Without the fix, first_bot would remain open (zombie), and both would
222      # receive every Discord event, causing double responses.
223      assert await adapter.connect() is True
224      assert len(created) == 2
225      second_bot = created[1]
226  
227      # The first bot must be closed before the second is assigned.
228      assert first_bot._closed is True, (
229          "First Discord client must be closed on re-entry of connect() to prevent "
230          "zombie websocket (#18187)"
231      )
232      assert second_bot._closed is False, "second bot should still be open"
233      assert adapter._client is second_bot
234  
235      await adapter.disconnect()
236  
237  
238  @pytest.mark.asyncio
239  async def test_connect_releases_token_lock_on_timeout(monkeypatch):
240      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
241  
242      monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
243      released = []
244      monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: released.append((scope, identity)))
245  
246      intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False)
247      monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
248  
249      monkeypatch.setattr(
250          discord_platform.commands,
251          "Bot",
252          lambda **kwargs: FakeBot(
253              intents=kwargs["intents"],
254              proxy=kwargs.get("proxy"),
255              allowed_mentions=kwargs.get("allowed_mentions"),
256          ),
257      )
258  
259      async def fake_wait_for(awaitable, timeout):
260          awaitable.close()
261          raise asyncio.TimeoutError()
262  
263      monkeypatch.setattr(discord_platform.asyncio, "wait_for", fake_wait_for)
264  
265      ok = await adapter.connect()
266  
267      assert ok is False
268      assert released == [("discord-bot-token", "test-token")]
269      assert adapter._platform_lock_identity is None
270  
271  
272  @pytest.mark.asyncio
273  async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
274      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
275  
276      monkeypatch.setenv("DISCORD_COMMAND_SYNC_POLICY", "bulk")
277      monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
278      monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
279  
280      intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False)
281      monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
282  
283      created = {}
284  
285      def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
286          bot = SlowSyncBot(intents=intents, proxy=proxy)
287          created["bot"] = bot
288          return bot
289  
290      monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
291      monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
292  
293      ok = await asyncio.wait_for(adapter.connect(), timeout=1.0)
294  
295      assert ok is True
296      assert adapter._ready_event.is_set()
297  
298      await asyncio.wait_for(created["bot"].tree.started.wait(), timeout=1.0)
299      assert created["bot"].tree.sync.await_count == 1
300  
301      created["bot"].tree.allow_finish.set()
302      await asyncio.sleep(0)
303      await adapter.disconnect()
304  
305  
306  @pytest.mark.asyncio
307  async def test_connect_respects_slash_commands_opt_out(monkeypatch):
308      adapter = DiscordAdapter(
309          PlatformConfig(enabled=True, token="test-token", extra={"slash_commands": False})
310      )
311  
312      monkeypatch.setenv("DISCORD_COMMAND_SYNC_POLICY", "off")
313      monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
314      monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
315  
316      intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False)
317      monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
318      monkeypatch.setattr(
319          discord_platform.commands,
320          "Bot",
321          lambda **kwargs: FakeBot(
322              intents=kwargs["intents"],
323              proxy=kwargs.get("proxy"),
324              allowed_mentions=kwargs.get("allowed_mentions"),
325          ),
326      )
327      register_mock = MagicMock()
328      monkeypatch.setattr(adapter, "_register_slash_commands", register_mock)
329      monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
330  
331      ok = await adapter.connect()
332  
333      assert ok is True
334      register_mock.assert_not_called()
335  
336      await adapter.disconnect()
337  
338  
339  @pytest.mark.asyncio
340  async def test_safe_sync_slash_commands_only_mutates_diffs():
341      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
342  
343      class _DesiredCommand:
344          def __init__(self, payload):
345              self._payload = payload
346  
347          def to_dict(self, tree):
348              assert tree is not None
349              return dict(self._payload)
350  
351      class _ExistingCommand:
352          def __init__(self, command_id, payload):
353              self.id = command_id
354              self.name = payload["name"]
355              self.type = SimpleNamespace(value=payload["type"])
356              self._payload = payload
357  
358          def to_dict(self):
359              return {
360                  "id": self.id,
361                  "application_id": 999,
362                  **self._payload,
363                  "name_localizations": {},
364                  "description_localizations": {},
365              }
366  
367      desired_same = {
368          "name": "status",
369          "description": "Show Hermes session status",
370          "type": 1,
371          "options": [],
372          "nsfw": False,
373          "dm_permission": True,
374          "default_member_permissions": None,
375      }
376      desired_updated = {
377          "name": "help",
378          "description": "Show available commands",
379          "type": 1,
380          "options": [],
381          "nsfw": False,
382          "dm_permission": True,
383          "default_member_permissions": None,
384      }
385      desired_created = {
386          "name": "metricas",
387          "description": "Show Colmeio metrics dashboard",
388          "type": 1,
389          "options": [],
390          "nsfw": False,
391          "dm_permission": True,
392          "default_member_permissions": None,
393      }
394      existing_same = _ExistingCommand(11, desired_same)
395      existing_updated = _ExistingCommand(
396          12,
397          {
398              **desired_updated,
399              "description": "Old help text",
400          },
401      )
402      existing_deleted = _ExistingCommand(
403          13,
404          {
405              "name": "old-command",
406              "description": "To be deleted",
407              "type": 1,
408              "options": [],
409              "nsfw": False,
410              "dm_permission": True,
411              "default_member_permissions": None,
412          },
413      )
414  
415      fake_tree = SimpleNamespace(
416          get_commands=lambda: [
417              _DesiredCommand(desired_same),
418              _DesiredCommand(desired_updated),
419              _DesiredCommand(desired_created),
420          ],
421          fetch_commands=AsyncMock(return_value=[existing_same, existing_updated, existing_deleted]),
422      )
423      fake_http = SimpleNamespace(
424          upsert_global_command=AsyncMock(),
425          edit_global_command=AsyncMock(),
426          delete_global_command=AsyncMock(),
427      )
428      adapter._client = SimpleNamespace(
429          tree=fake_tree,
430          http=fake_http,
431          application_id=999,
432          user=SimpleNamespace(id=999),
433      )
434  
435      summary = await adapter._safe_sync_slash_commands()
436  
437      assert summary == {
438          "total": 3,
439          "unchanged": 1,
440          "updated": 1,
441          "recreated": 0,
442          "created": 1,
443          "deleted": 1,
444      }
445      fake_http.edit_global_command.assert_awaited_once_with(999, 12, desired_updated)
446      fake_http.upsert_global_command.assert_awaited_once_with(999, desired_created)
447      fake_http.delete_global_command.assert_awaited_once_with(999, 13)
448  
449  
450  @pytest.mark.asyncio
451  async def test_safe_sync_slash_commands_recreates_metadata_only_diffs():
452      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
453  
454      class _DesiredCommand:
455          def __init__(self, payload):
456              self._payload = payload
457  
458          def to_dict(self, tree):
459              assert tree is not None
460              return dict(self._payload)
461  
462      class _ExistingCommand:
463          def __init__(self, command_id, payload):
464              self.id = command_id
465              self.name = payload["name"]
466              self.type = SimpleNamespace(value=payload["type"])
467              self._payload = payload
468  
469          def to_dict(self):
470              return {
471                  "id": self.id,
472                  "application_id": 999,
473                  **self._payload,
474                  "name_localizations": {},
475                  "description_localizations": {},
476              }
477  
478      desired = {
479          "name": "help",
480          "description": "Show available commands",
481          "type": 1,
482          "options": [],
483          "nsfw": False,
484          "dm_permission": True,
485          "default_member_permissions": "8",
486      }
487      existing = _ExistingCommand(
488          12,
489          {
490              **desired,
491              "default_member_permissions": None,
492          },
493      )
494  
495      fake_tree = SimpleNamespace(
496          get_commands=lambda: [_DesiredCommand(desired)],
497          fetch_commands=AsyncMock(return_value=[existing]),
498      )
499      fake_http = SimpleNamespace(
500          upsert_global_command=AsyncMock(),
501          edit_global_command=AsyncMock(),
502          delete_global_command=AsyncMock(),
503      )
504      adapter._client = SimpleNamespace(
505          tree=fake_tree,
506          http=fake_http,
507          application_id=999,
508          user=SimpleNamespace(id=999),
509      )
510  
511      summary = await adapter._safe_sync_slash_commands()
512  
513      assert summary == {
514          "total": 1,
515          "unchanged": 0,
516          "updated": 0,
517          "recreated": 1,
518          "created": 0,
519          "deleted": 0,
520      }
521      fake_http.edit_global_command.assert_not_awaited()
522      fake_http.delete_global_command.assert_awaited_once_with(999, 12)
523      fake_http.upsert_global_command.assert_awaited_once_with(999, desired)
524  
525  
526  @pytest.mark.asyncio
527  async def test_post_connect_initialization_skips_sync_when_policy_off(monkeypatch):
528      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
529      monkeypatch.setenv("DISCORD_COMMAND_SYNC_POLICY", "off")
530  
531      fake_tree = SimpleNamespace(sync=AsyncMock())
532      adapter._client = SimpleNamespace(tree=fake_tree)
533  
534      await adapter._run_post_connect_initialization()
535  
536      fake_tree.sync.assert_not_called()
537  
538  
539  @pytest.mark.asyncio
540  async def test_safe_sync_reads_permission_attrs_from_existing_command():
541      """Regression: AppCommand.to_dict() in discord.py does NOT include
542      nsfw, dm_permission, or default_member_permissions — they live only
543      on the attributes. Without reading those attrs, any command with
544      non-default permissions false-diffs on every startup.
545      """
546      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
547  
548      class _DesiredCommand:
549          def __init__(self, payload):
550              self._payload = payload
551  
552          def to_dict(self, tree):
553              return dict(self._payload)
554  
555      class _ExistingCommand:
556          """Mirrors discord.py's AppCommand — to_dict() omits nsfw/dm/perms."""
557  
558          def __init__(self, command_id, name, description, *, nsfw, guild_only, default_permissions):
559              self.id = command_id
560              self.name = name
561              self.description = description
562              self.type = SimpleNamespace(value=1)
563              self.nsfw = nsfw
564              self.guild_only = guild_only
565              self.default_member_permissions = (
566                  SimpleNamespace(value=default_permissions)
567                  if default_permissions is not None
568                  else None
569              )
570  
571          def to_dict(self):
572              # Match real AppCommand.to_dict() — no nsfw/dm_permission/default_member_permissions
573              return {
574                  "id": self.id,
575                  "type": 1,
576                  "application_id": 999,
577                  "name": self.name,
578                  "description": self.description,
579                  "name_localizations": {},
580                  "description_localizations": {},
581                  "options": [],
582              }
583  
584      desired = {
585          "name": "admin",
586          "description": "Admin-only command",
587          "type": 1,
588          "options": [],
589          "nsfw": True,
590          "dm_permission": False,
591          "default_member_permissions": "8",
592      }
593      # Existing command has matching attrs — should report unchanged, NOT falsely diff.
594      existing = _ExistingCommand(
595          42,
596          "admin",
597          "Admin-only command",
598          nsfw=True,
599          guild_only=True,
600          default_permissions=8,
601      )
602  
603      fake_tree = SimpleNamespace(
604          get_commands=lambda: [_DesiredCommand(desired)],
605          fetch_commands=AsyncMock(return_value=[existing]),
606      )
607      fake_http = SimpleNamespace(
608          upsert_global_command=AsyncMock(),
609          edit_global_command=AsyncMock(),
610          delete_global_command=AsyncMock(),
611      )
612      adapter._client = SimpleNamespace(
613          tree=fake_tree,
614          http=fake_http,
615          application_id=999,
616          user=SimpleNamespace(id=999),
617      )
618  
619      summary = await adapter._safe_sync_slash_commands()
620  
621      # Without the fix, this would be unchanged=0, recreated=1 (false diff).
622      assert summary == {
623          "total": 1,
624          "unchanged": 1,
625          "updated": 0,
626          "recreated": 0,
627          "created": 0,
628          "deleted": 0,
629      }
630      fake_http.edit_global_command.assert_not_awaited()
631      fake_http.delete_global_command.assert_not_awaited()
632      fake_http.upsert_global_command.assert_not_awaited()
633  
634  
635  @pytest.mark.asyncio
636  async def test_safe_sync_detects_contexts_drift():
637      """Regression: contexts and integration_types must be canonicalized
638      so drift in those fields triggers reconciliation. Without this, the
639      diff silently reports 'unchanged' and never reconciles.
640      """
641      adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
642  
643      class _DesiredCommand:
644          def __init__(self, payload):
645              self._payload = payload
646  
647          def to_dict(self, tree):
648              return dict(self._payload)
649  
650      class _ExistingCommand:
651          def __init__(self, command_id, payload):
652              self.id = command_id
653              self.name = payload["name"]
654              self.description = payload["description"]
655              self.type = SimpleNamespace(value=1)
656              self.nsfw = payload.get("nsfw", False)
657              self.guild_only = not payload.get("dm_permission", True)
658              self.default_member_permissions = None
659              self._payload = payload
660  
661          def to_dict(self):
662              return {
663                  "id": self.id,
664                  "type": 1,
665                  "application_id": 999,
666                  "name": self.name,
667                  "description": self.description,
668                  "name_localizations": {},
669                  "description_localizations": {},
670                  "options": [],
671                  "contexts": self._payload.get("contexts"),
672                  "integration_types": self._payload.get("integration_types"),
673              }
674  
675      desired = {
676          "name": "help",
677          "description": "Show available commands",
678          "type": 1,
679          "options": [],
680          "nsfw": False,
681          "dm_permission": True,
682          "default_member_permissions": None,
683          "contexts": [0, 1, 2],
684          "integration_types": [0, 1],
685      }
686      existing = _ExistingCommand(
687          77,
688          {
689              **desired,
690              "contexts": [0],  # server-side only
691              "integration_types": [0],
692          },
693      )
694  
695      fake_tree = SimpleNamespace(
696          get_commands=lambda: [_DesiredCommand(desired)],
697          fetch_commands=AsyncMock(return_value=[existing]),
698      )
699      fake_http = SimpleNamespace(
700          upsert_global_command=AsyncMock(),
701          edit_global_command=AsyncMock(),
702          delete_global_command=AsyncMock(),
703      )
704      adapter._client = SimpleNamespace(
705          tree=fake_tree,
706          http=fake_http,
707          application_id=999,
708          user=SimpleNamespace(id=999),
709      )
710  
711      summary = await adapter._safe_sync_slash_commands()
712  
713      # contexts and integration_types are not patchable by
714      # edit_global_command, so the command must be recreated.
715      assert summary["unchanged"] == 0
716      assert summary["recreated"] == 1
717      assert summary["updated"] == 0
718      fake_http.edit_global_command.assert_not_awaited()
719      fake_http.delete_global_command.assert_awaited_once_with(999, 77)
720      fake_http.upsert_global_command.assert_awaited_once_with(999, desired)