test(discord): add regression test for 100-command sync limit

Add a test to verify that _safe_sync_slash_commands deletes obsolete
commands before creating new ones. This ensures we never temporarily
exceed Discord's 100-command limit during sync, which would trigger
error 30032 and break all slash commands.

This test guards against the regression where sync could fail even though
the registration cap was properly enforced.
This commit is contained in:
infinitycrew39 2026-06-22 22:51:50 +07:00 committed by Teknium
parent ae7e857420
commit 91c465f6e7

View file

@ -0,0 +1,140 @@
"""Test Discord slash command sync respects the 100-command hard limit."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import sys
import pytest
from gateway.config import PlatformConfig
def _ensure_discord_mock():
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return
if sys.modules.get("discord") is None:
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
sys.modules["discord"] = discord_mod
sys.modules["discord.ext"] = MagicMock()
sys.modules["discord.ext.commands"] = MagicMock()
_ensure_discord_mock()
from plugins.platforms.discord.adapter import DiscordAdapter
class _FakeTreeCommand:
"""Minimal command stub matching discord.py tree command API."""
def __init__(self, name: str, command_type: int = 1):
self.name = name
self.type = command_type
def to_dict(self, _tree):
return {"name": self.name, "type": self.type}
@pytest.fixture
def adapter():
"""Create a Discord adapter with mocked Discord client."""
_ensure_discord_mock()
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)
# Mock the Discord client and tree
adapter._client = MagicMock()
adapter._client.tree = MagicMock()
adapter._client.http = AsyncMock()
adapter._client.application_id = "test_app_id"
adapter._sleep_between_command_sync_mutations = AsyncMock()
adapter._existing_command_to_payload = MagicMock(side_effect=lambda cmd: {"name": cmd.name})
adapter._canonicalize_app_command_payload = MagicMock(side_effect=lambda p: p)
adapter._patchable_app_command_payload = MagicMock(side_effect=lambda p: p)
return adapter
@pytest.mark.asyncio
async def test_safe_sync_deletes_before_creating():
"""Sync must delete obsolete commands BEFORE creating new ones.
Discord's 100-command limit is enforced when trying to upsert. If we
have 100 commands on Discord, try to add 1 new one, and haven't deleted
any yet, Discord rejects with error 30032.
The fix: identify and delete obsolete commands first, then create/update.
This ensures we never temporarily exceed 100 during the sync operation.
This is a regression guard for the samuraiheart bug where sync would fail
with error 30032 even though the registration code properly capped at 100.
"""
_ensure_discord_mock()
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)
adapter._client = MagicMock()
adapter._client.tree = MagicMock()
adapter._client.http = AsyncMock()
adapter._client.application_id = "test_app_id"
adapter._sleep_between_command_sync_mutations = AsyncMock()
adapter._existing_command_to_payload = MagicMock(side_effect=lambda cmd: {"name": cmd.name})
adapter._canonicalize_app_command_payload = MagicMock(side_effect=lambda p: p)
adapter._patchable_app_command_payload = MagicMock(side_effect=lambda p: p)
# Simulate having 100 commands on Discord, with 1 that's no longer desired
# and 1 new command that should be created.
# Existing on Discord: cmd_0, cmd_1, ..., cmd_99 (100 total)
# Desired locally: cmd_1, cmd_2, ..., cmd_99, cmd_new (100 total)
# So: delete cmd_0 (1 deletion), create cmd_new (1 creation)
existing_commands = [
SimpleNamespace(id=f"id_{i}", name=f"cmd_{i}", type=1)
for i in range(100)
]
adapter._client.tree.fetch_commands = AsyncMock(return_value=existing_commands)
adapter._client.tree.get_commands = MagicMock(
return_value=[
_FakeTreeCommand(name=f"cmd_{i}", command_type=1)
for i in range(1, 100)
] + [_FakeTreeCommand(name="cmd_new", command_type=1)]
)
# Track the order of mutations
mutation_log = []
async def mock_delete(*args):
mutation_log.append(("delete", args[-1]))
async def mock_upsert(*args):
mutation_log.append(("create", args[-1].get("name")))
adapter._client.http.delete_global_command = mock_delete
adapter._client.http.upsert_global_command = mock_upsert
adapter._client.http.edit_global_command = AsyncMock()
# Call sync
await adapter._safe_sync_slash_commands()
# Verify that:
# 1. A deletion happened (cmd_0)
# 2. It happened BEFORE any creation
# 3. The creation of cmd_new happened AFTER deletion
deletes = [m for m in mutation_log if m[0] == "delete"]
creates = [m for m in mutation_log if m[0] == "create"]
assert len(deletes) >= 1, "At least one command should be deleted"
assert len(creates) >= 1, "At least one command should be created"
# The key assertion: all deletions should come before all creations.
# Find the index of the last delete and the first create.
last_delete_idx = max(i for i, m in enumerate(mutation_log) if m[0] == "delete")
first_create_idx = min(i for i, m in enumerate(mutation_log) if m[0] == "create")
assert last_delete_idx < first_create_idx, (
f"Deletions must happen before creations to avoid exceeding 100-command limit. "
f"Last delete at index {last_delete_idx}, first create at index {first_create_idx}"
)