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)