diff --git a/src/cmds/core/admin.py b/src/cmds/core/admin.py index 63dc32b..791482d 100644 --- a/src/cmds/core/admin.py +++ b/src/cmds/core/admin.py @@ -3,37 +3,153 @@ import logging import discord +from discord import ApplicationContext, Interaction, Option, WebhookMessage from discord.ext import commands +from discord.ext.commands import has_any_role from src.bot import Bot from src.core import settings +from src.database.models.dynamic_role import RoleCategory logger = logging.getLogger(__name__) -# Top-level admin command group -# Subcommands (like 'role') are registered by other cogs using admin.create_subgroup() -admin = discord.SlashCommandGroup( - "admin", - "Bot administration commands", - guild_ids=settings.guild_ids, -) +CATEGORY_CHOICES = [c.value for c in RoleCategory] class AdminCog(commands.Cog): - """Admin commands placeholder cog. - - This cog doesn't define any commands directly - it just ensures the - /admin group is registered. Subcommands are added by other cogs - via admin.create_subgroup(). - """ + """Admin commands for bot administration.""" def __init__(self, bot: Bot): self.bot = bot + admin = discord.SlashCommandGroup( + "admin", + "Bot administration commands", + guild_ids=settings.guild_ids, + ) + + role = admin.create_subgroup( + "role", + "Manage dynamic Discord roles", + ) + + @role.command(description="Add a new dynamic role.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def add( + self, + ctx: ApplicationContext, + category: Option(str, "Role category", choices=CATEGORY_CHOICES), + key: Option(str, "Lookup key (e.g. 'Omniscient', 'CWPE')"), + role: Option(discord.Role, "The Discord role"), + display_name: Option(str, "Human-readable name"), + description: Option(str, "Description (for joinable roles)", required=False), + cert_full_name: Option(str, "Full cert name from HTB platform (academy_cert only)", required=False), + cert_integer_id: Option(int, "Platform cert ID (academy_cert only)", required=False), + ) -> Interaction | WebhookMessage: + """Add a new dynamic role to the database.""" + try: + cat = RoleCategory(category) + except ValueError: + return await ctx.respond(f"Invalid category: {category}", ephemeral=True) + + await self.bot.role_manager.add_role( + key=key, + category=cat, + discord_role_id=role.id, + display_name=display_name, + description=description, + cert_full_name=cert_full_name, + cert_integer_id=cert_integer_id, + ) + return await ctx.respond( + f"Added dynamic role: `{category}/{key}` -> {role.mention}", + ephemeral=True, + ) + + @role.command(description="Remove a dynamic role.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def remove( + self, + ctx: ApplicationContext, + category: Option(str, "Role category", choices=CATEGORY_CHOICES), + key: Option(str, "Lookup key to remove"), + ) -> Interaction | WebhookMessage: + """Remove a dynamic role from the database.""" + try: + cat = RoleCategory(category) + except ValueError: + return await ctx.respond(f"Invalid category: {category}", ephemeral=True) + + deleted = await self.bot.role_manager.remove_role(cat, key) + if deleted: + return await ctx.respond(f"Removed dynamic role: `{category}/{key}`", ephemeral=True) + return await ctx.respond(f"No role found for `{category}/{key}`", ephemeral=True) + + @role.command(description="Update a dynamic role's Discord role.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def update( + self, + ctx: ApplicationContext, + category: Option(str, "Role category", choices=CATEGORY_CHOICES), + key: Option(str, "Lookup key to update"), + role: Option(discord.Role, "The new Discord role"), + ) -> Interaction | WebhookMessage: + """Update a dynamic role's Discord ID.""" + try: + cat = RoleCategory(category) + except ValueError: + return await ctx.respond(f"Invalid category: {category}", ephemeral=True) + + updated = await self.bot.role_manager.update_role(cat, key, role.id) + if updated: + return await ctx.respond( + f"Updated `{category}/{key}` -> {role.mention}", + ephemeral=True, + ) + return await ctx.respond(f"No role found for `{category}/{key}`", ephemeral=True) + + @role.command(description="List configured dynamic roles.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS")) + async def list( + self, + ctx: ApplicationContext, + category: Option(str, "Filter by category", choices=CATEGORY_CHOICES, required=False), + ) -> Interaction | WebhookMessage: + """List all configured dynamic roles.""" + cat = RoleCategory(category) if category else None + roles = await self.bot.role_manager.list_roles(cat) + + if not roles: + return await ctx.respond("No dynamic roles configured.", ephemeral=True) + + # Group by category for display + grouped: dict[str, list[str]] = {} + for r in roles: + cat_name = r.category.value + if cat_name not in grouped: + grouped[cat_name] = [] + guild_role = ctx.guild.get_role(r.discord_role_id) + role_mention = guild_role.mention if guild_role else f"`{r.discord_role_id}`" + grouped[cat_name].append(f"`{r.key}` -> {role_mention} ({r.display_name})") + + embed = discord.Embed(title="Dynamic Roles", color=0x9ACC14) + for cat_name, entries in grouped.items(): + embed.add_field( + name=cat_name, + value="\n".join(entries[:10]) + (f"\n... and {len(entries) - 10} more" if len(entries) > 10 else ""), + inline=False, + ) + + return await ctx.respond(embed=embed, ephemeral=True) + + @role.command(description="Force reload dynamic roles from database.") + @has_any_role(*settings.role_groups.get("ALL_ADMINS")) + async def reload(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: + """Force reload the role manager cache from the database.""" + await self.bot.role_manager.reload() + return await ctx.respond("Dynamic roles reloaded from database.", ephemeral=True) + def setup(bot: Bot) -> None: - """Load the AdminCog and register the admin command group.""" - cog = AdminCog(bot) - bot.add_cog(cog) - # Register the admin group at module level so other cogs can add subgroups - bot.add_application_command(admin) + """Load the AdminCog.""" + bot.add_cog(AdminCog(bot)) diff --git a/src/cmds/core/role_admin.py b/src/cmds/core/role_admin.py deleted file mode 100644 index bbe53a2..0000000 --- a/src/cmds/core/role_admin.py +++ /dev/null @@ -1,149 +0,0 @@ -import logging - -import discord -from discord import ApplicationContext, Interaction, Option, WebhookMessage, slash_command -from discord.ext import commands -from discord.ext.commands import has_any_role - -from src.bot import Bot -from src.cmds.core.admin import admin # Import the admin group from admin.py -from src.core import settings -from src.database.models.dynamic_role import RoleCategory - -logger = logging.getLogger(__name__) - -CATEGORY_CHOICES = [c.value for c in RoleCategory] - -# Create the role subgroup under the admin group -role = admin.create_subgroup( - "role", - "Manage dynamic Discord roles", -) - - -class RoleAdminCog(commands.Cog): - """Admin commands for managing dynamic Discord roles.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @role.command(description="Add a new dynamic role.") - @has_any_role(*settings.role_groups.get("ALL_ADMINS")) - async def add( - self, - ctx: ApplicationContext, - category: Option(str, "Role category", choices=CATEGORY_CHOICES), - key: Option(str, "Lookup key (e.g. 'Omniscient', 'CWPE')"), - role: Option(discord.Role, "The Discord role"), - display_name: Option(str, "Human-readable name"), - description: Option(str, "Description (for joinable roles)", required=False), - cert_full_name: Option(str, "Full cert name from HTB platform (academy_cert only)", required=False), - cert_integer_id: Option(int, "Platform cert ID (academy_cert only)", required=False), - ) -> Interaction | WebhookMessage: - """Add a new dynamic role to the database.""" - try: - cat = RoleCategory(category) - except ValueError: - return await ctx.respond(f"Invalid category: {category}", ephemeral=True) - - await self.bot.role_manager.add_role( - key=key, - category=cat, - discord_role_id=role.id, - display_name=display_name, - description=description, - cert_full_name=cert_full_name, - cert_integer_id=cert_integer_id, - ) - return await ctx.respond( - f"Added dynamic role: `{category}/{key}` -> {role.mention}", - ephemeral=True, - ) - - @role.command(description="Remove a dynamic role.") - @has_any_role(*settings.role_groups.get("ALL_ADMINS")) - async def remove( - self, - ctx: ApplicationContext, - category: Option(str, "Role category", choices=CATEGORY_CHOICES), - key: Option(str, "Lookup key to remove"), - ) -> Interaction | WebhookMessage: - """Remove a dynamic role from the database.""" - try: - cat = RoleCategory(category) - except ValueError: - return await ctx.respond(f"Invalid category: {category}", ephemeral=True) - - deleted = await self.bot.role_manager.remove_role(cat, key) - if deleted: - return await ctx.respond(f"Removed dynamic role: `{category}/{key}`", ephemeral=True) - return await ctx.respond(f"No role found for `{category}/{key}`", ephemeral=True) - - @role.command(description="Update a dynamic role's Discord role.") - @has_any_role(*settings.role_groups.get("ALL_ADMINS")) - async def update( - self, - ctx: ApplicationContext, - category: Option(str, "Role category", choices=CATEGORY_CHOICES), - key: Option(str, "Lookup key to update"), - role: Option(discord.Role, "The new Discord role"), - ) -> Interaction | WebhookMessage: - """Update a dynamic role's Discord ID.""" - try: - cat = RoleCategory(category) - except ValueError: - return await ctx.respond(f"Invalid category: {category}", ephemeral=True) - - updated = await self.bot.role_manager.update_role(cat, key, role.id) - if updated: - return await ctx.respond( - f"Updated `{category}/{key}` -> {role.mention}", - ephemeral=True, - ) - return await ctx.respond(f"No role found for `{category}/{key}`", ephemeral=True) - - @role.command(description="List configured dynamic roles.") - @has_any_role(*settings.role_groups.get("ALL_ADMINS"), *settings.role_groups.get("ALL_MODS")) - async def list( - self, - ctx: ApplicationContext, - category: Option(str, "Filter by category", choices=CATEGORY_CHOICES, required=False), - ) -> Interaction | WebhookMessage: - """List all configured dynamic roles.""" - cat = RoleCategory(category) if category else None - roles = await self.bot.role_manager.list_roles(cat) - - if not roles: - return await ctx.respond("No dynamic roles configured.", ephemeral=True) - - # Group by category for display - grouped: dict[str, list[str]] = {} - for r in roles: - cat_name = r.category.value - if cat_name not in grouped: - grouped[cat_name] = [] - guild_role = ctx.guild.get_role(r.discord_role_id) - role_mention = guild_role.mention if guild_role else f"`{r.discord_role_id}`" - grouped[cat_name].append(f"`{r.key}` -> {role_mention} ({r.display_name})") - - embed = discord.Embed(title="Dynamic Roles", color=0x9ACC14) - for cat_name, entries in grouped.items(): - embed.add_field( - name=cat_name, - value="\n".join(entries[:10]) + (f"\n... and {len(entries) - 10} more" if len(entries) > 10 else ""), - inline=False, - ) - - return await ctx.respond(embed=embed, ephemeral=True) - - @role.command(description="Force reload dynamic roles from database.") - @has_any_role(*settings.role_groups.get("ALL_ADMINS")) - async def reload(self, ctx: ApplicationContext) -> Interaction | WebhookMessage: - """Force reload the role manager cache from the database.""" - await self.bot.role_manager.reload() - return await ctx.respond("Dynamic roles reloaded from database.", ephemeral=True) - - -def setup(bot: Bot) -> None: - """Load the `RoleAdminCog` cog.""" - bot.add_cog(RoleAdminCog(bot)) diff --git a/tests/src/cmds/core/test_admin.py b/tests/src/cmds/core/test_admin.py new file mode 100644 index 0000000..6c37c90 --- /dev/null +++ b/tests/src/cmds/core/test_admin.py @@ -0,0 +1,290 @@ +"""Tests for Admin cog.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.cmds.core import admin +from src.database.models.dynamic_role import DynamicRole, RoleCategory +from tests import helpers + + +class TestAdminCog: + """Test the Admin cog.""" + + @pytest.mark.asyncio + async def test_add_role_success(self, ctx, bot): + """Test adding a dynamic role.""" + mock_role = MagicMock() + mock_role.id = 123456789 + mock_role.mention = "<@&123456789>" + + bot.role_manager = MagicMock() + bot.role_manager.add_role = AsyncMock() + + cog = admin.AdminCog(bot) + await cog.add.callback( + cog, ctx, "rank", "TestRole", mock_role, "Test Display Name", + None, None, None # description, cert_full_name, cert_integer_id + ) + + bot.role_manager.add_role.assert_called_once_with( + key="TestRole", + category=RoleCategory.RANK, + discord_role_id=123456789, + display_name="Test Display Name", + description=None, + cert_full_name=None, + cert_integer_id=None, + ) + ctx.respond.assert_called_once_with( + "Added dynamic role: `rank/TestRole` -> <@&123456789>", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_add_role_with_cert(self, ctx, bot): + """Test adding a cert role with extra fields.""" + mock_role = MagicMock() + mock_role.id = 123456789 + mock_role.mention = "<@&123456789>" + + bot.role_manager = MagicMock() + bot.role_manager.add_role = AsyncMock() + + cog = admin.AdminCog(bot) + await cog.add.callback( + cog, + ctx, + "academy_cert", + "CPTS", + mock_role, + "CPTS Cert", + None, # description + "HTB Certified Penetration Testing Specialist", + 3, + ) + + bot.role_manager.add_role.assert_called_once_with( + key="CPTS", + category=RoleCategory.ACADEMY_CERT, + discord_role_id=123456789, + display_name="CPTS Cert", + description=None, + cert_full_name="HTB Certified Penetration Testing Specialist", + cert_integer_id=3, + ) + + @pytest.mark.asyncio + async def test_add_role_invalid_category(self, ctx, bot): + """Test adding a role with invalid category.""" + mock_role = MagicMock() + + cog = admin.AdminCog(bot) + await cog.add.callback( + cog, ctx, "invalid_category", "TestRole", mock_role, "Test", + None, None, None # description, cert_full_name, cert_integer_id + ) + + ctx.respond.assert_called_once_with( + "Invalid category: invalid_category", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_remove_role_success(self, ctx, bot): + """Test removing a dynamic role successfully.""" + bot.role_manager = MagicMock() + bot.role_manager.remove_role = AsyncMock(return_value=True) + + cog = admin.AdminCog(bot) + await cog.remove.callback(cog, ctx, "rank", "Omniscient") + + bot.role_manager.remove_role.assert_called_once_with( + RoleCategory.RANK, + "Omniscient", + ) + ctx.respond.assert_called_once_with( + "Removed dynamic role: `rank/Omniscient`", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_remove_role_not_found(self, ctx, bot): + """Test removing a role that doesn't exist.""" + bot.role_manager = MagicMock() + bot.role_manager.remove_role = AsyncMock(return_value=False) + + cog = admin.AdminCog(bot) + await cog.remove.callback(cog, ctx, "rank", "NonExistent") + + ctx.respond.assert_called_once_with( + "No role found for `rank/NonExistent`", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_update_role_success(self, ctx, bot): + """Test updating a dynamic role successfully.""" + mock_role = MagicMock() + mock_role.id = 987654321 + mock_role.mention = "<@&987654321>" + + bot.role_manager = MagicMock() + bot.role_manager.update_role = AsyncMock(return_value=True) + + cog = admin.AdminCog(bot) + await cog.update.callback(cog, ctx, "rank", "Omniscient", mock_role) + + bot.role_manager.update_role.assert_called_once_with( + RoleCategory.RANK, + "Omniscient", + 987654321, + ) + ctx.respond.assert_called_once_with( + "Updated `rank/Omniscient` -> <@&987654321>", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_update_role_not_found(self, ctx, bot): + """Test updating a role that doesn't exist.""" + mock_role = MagicMock() + mock_role.id = 987654321 + + bot.role_manager = MagicMock() + bot.role_manager.update_role = AsyncMock(return_value=False) + + cog = admin.AdminCog(bot) + await cog.update.callback(cog, ctx, "rank", "NonExistent", mock_role) + + ctx.respond.assert_called_once_with( + "No role found for `rank/NonExistent`", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_list_roles_empty(self, ctx, bot): + """Test listing roles when none are configured.""" + bot.role_manager = MagicMock() + bot.role_manager.list_roles = AsyncMock(return_value=[]) + + cog = admin.AdminCog(bot) + await cog.list.callback(cog, ctx, None) + + ctx.respond.assert_called_once_with( + "No dynamic roles configured.", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_list_roles_with_data(self, ctx, bot, guild): + """Test listing roles with data.""" + ctx.guild = guild + + mock_roles = [ + DynamicRole( + id=1, + key="Omniscient", + discord_role_id=586528519459438592, + category=RoleCategory.RANK, + display_name="Omniscient", + ), + DynamicRole( + id=2, + key="Hacker", + discord_role_id=586528079363702801, + category=RoleCategory.RANK, + display_name="Hacker", + ), + ] + + bot.role_manager = MagicMock() + bot.role_manager.list_roles = AsyncMock(return_value=mock_roles) + + cog = admin.AdminCog(bot) + await cog.list.callback(cog, ctx, None) + + # Should respond with an embed + assert ctx.respond.called + call_args = ctx.respond.call_args + assert call_args.kwargs.get("ephemeral") is True + assert "embed" in call_args.kwargs + + @pytest.mark.asyncio + async def test_reload_success(self, ctx, bot): + """Test reloading dynamic roles.""" + bot.role_manager = MagicMock() + bot.role_manager.reload = AsyncMock() + + cog = admin.AdminCog(bot) + await cog.reload.callback(cog, ctx) + + bot.role_manager.reload.assert_called_once() + ctx.respond.assert_called_once_with( + "Dynamic roles reloaded from database.", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_remove_role_invalid_category(self, ctx, bot): + """Test removing with an invalid category returns an error.""" + cog = admin.AdminCog(bot) + await cog.remove.callback(cog, ctx, "bad_cat", "Key") + + ctx.respond.assert_called_once_with( + "Invalid category: bad_cat", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_update_role_invalid_category(self, ctx, bot): + """Test updating with an invalid category returns an error.""" + mock_role = helpers.MockRole(id=7777, name="NewRole") + + cog = admin.AdminCog(bot) + await cog.update.callback(cog, ctx, "bad_cat", "Key", mock_role) + + ctx.respond.assert_called_once_with( + "Invalid category: bad_cat", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_list_roles_with_category_filter(self, ctx, bot): + """Test listing dynamic roles filtered by a specific category.""" + bot.role_manager = MagicMock() + bot.role_manager.list_roles = AsyncMock(return_value=[]) + + cog = admin.AdminCog(bot) + await cog.list.callback(cog, ctx, "rank") + + bot.role_manager.list_roles.assert_called_once_with(RoleCategory.RANK) + + @pytest.mark.asyncio + async def test_list_role_not_in_guild(self, ctx, bot): + """Test listing when a role ID does not exist in the guild (shows raw ID).""" + mock_role_entry = MagicMock() + mock_role_entry.category = RoleCategory.RANK + mock_role_entry.key = "Ghost" + mock_role_entry.discord_role_id = 99999 + mock_role_entry.display_name = "Ghost Rank" + + ctx.guild.get_role = MagicMock(return_value=None) + bot.role_manager = MagicMock() + bot.role_manager.list_roles = AsyncMock(return_value=[mock_role_entry]) + + cog = admin.AdminCog(bot) + await cog.list.callback(cog, ctx, None) + + ctx.respond.assert_called_once() + call_args = ctx.respond.call_args + assert call_args.kwargs.get("ephemeral") is True + embed = call_args.kwargs["embed"] + # When guild role is not found, the raw ID should be shown + assert "99999" in embed.fields[0].value + + def test_setup(self, bot): + """Test the setup function registers the cog.""" + admin.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/src/cmds/core/test_role_admin.py b/tests/src/cmds/core/test_role_admin.py deleted file mode 100644 index 0014648..0000000 --- a/tests/src/cmds/core/test_role_admin.py +++ /dev/null @@ -1,227 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from src.cmds.core import role_admin -from src.cmds.core.role_admin import RoleAdminCog -from src.database.models.dynamic_role import RoleCategory -from tests import helpers - - -class TestRoleAdminCog: - """Test the `RoleAdminCog` cog.""" - - # ── add command ────────────────────────────────────────────────── - - @pytest.mark.asyncio - async def test_add_success(self, ctx, bot): - """Test adding a dynamic role with valid parameters.""" - bot.role_manager.add_role = AsyncMock() - discord_role = helpers.MockRole(id=9999, name="TestRole") - - cog = RoleAdminCog(bot) - await cog.add.callback( - cog, - ctx, - category="rank", - key="Omniscient", - role=discord_role, - display_name="Omniscient Rank", - description=None, - cert_full_name=None, - cert_integer_id=None, - ) - - bot.role_manager.add_role.assert_awaited_once_with( - key="Omniscient", - category=RoleCategory.RANK, - discord_role_id=discord_role.id, - display_name="Omniscient Rank", - description=None, - cert_full_name=None, - cert_integer_id=None, - ) - ctx.respond.assert_awaited_once() - assert "Added dynamic role" in ctx.respond.call_args[0][0] - - @pytest.mark.asyncio - async def test_add_invalid_category(self, ctx, bot): - """Test adding a role with an invalid category returns an error.""" - discord_role = helpers.MockRole(id=9999, name="TestRole") - - cog = RoleAdminCog(bot) - await cog.add.callback( - cog, - ctx, - category="not_a_real_category", - key="Key", - role=discord_role, - display_name="Display", - description=None, - cert_full_name=None, - cert_integer_id=None, - ) - - ctx.respond.assert_awaited_once() - assert "Invalid category" in ctx.respond.call_args[0][0] - - # ── remove command ─────────────────────────────────────────────── - - @pytest.mark.asyncio - async def test_remove_success(self, ctx, bot): - """Test removing an existing dynamic role.""" - bot.role_manager.remove_role = AsyncMock(return_value=True) - - cog = RoleAdminCog(bot) - await cog.remove.callback(cog, ctx, category="rank", key="Omniscient") - - bot.role_manager.remove_role.assert_awaited_once_with(RoleCategory.RANK, "Omniscient") - ctx.respond.assert_awaited_once() - assert "Removed dynamic role" in ctx.respond.call_args[0][0] - - @pytest.mark.asyncio - async def test_remove_not_found(self, ctx, bot): - """Test removing a non-existent dynamic role.""" - bot.role_manager.remove_role = AsyncMock(return_value=False) - - cog = RoleAdminCog(bot) - await cog.remove.callback(cog, ctx, category="rank", key="NonExistent") - - ctx.respond.assert_awaited_once() - assert "No role found" in ctx.respond.call_args[0][0] - - @pytest.mark.asyncio - async def test_remove_invalid_category(self, ctx, bot): - """Test removing with an invalid category returns an error.""" - cog = RoleAdminCog(bot) - await cog.remove.callback(cog, ctx, category="bad_cat", key="Key") - - ctx.respond.assert_awaited_once() - assert "Invalid category" in ctx.respond.call_args[0][0] - - # ── update command ─────────────────────────────────────────────── - - @pytest.mark.asyncio - async def test_update_success(self, ctx, bot): - """Test updating an existing dynamic role.""" - bot.role_manager.update_role = AsyncMock(return_value=True) - new_role = helpers.MockRole(id=7777, name="NewRole") - - cog = RoleAdminCog(bot) - await cog.update.callback(cog, ctx, category="rank", key="Omniscient", role=new_role) - - bot.role_manager.update_role.assert_awaited_once_with(RoleCategory.RANK, "Omniscient", new_role.id) - ctx.respond.assert_awaited_once() - assert "Updated" in ctx.respond.call_args[0][0] - - @pytest.mark.asyncio - async def test_update_not_found(self, ctx, bot): - """Test updating a non-existent dynamic role.""" - bot.role_manager.update_role = AsyncMock(return_value=None) - new_role = helpers.MockRole(id=7777, name="NewRole") - - cog = RoleAdminCog(bot) - await cog.update.callback(cog, ctx, category="rank", key="NonExistent", role=new_role) - - ctx.respond.assert_awaited_once() - assert "No role found" in ctx.respond.call_args[0][0] - - @pytest.mark.asyncio - async def test_update_invalid_category(self, ctx, bot): - """Test updating with an invalid category returns an error.""" - new_role = helpers.MockRole(id=7777, name="NewRole") - - cog = RoleAdminCog(bot) - await cog.update.callback(cog, ctx, category="bad_cat", key="Key", role=new_role) - - ctx.respond.assert_awaited_once() - assert "Invalid category" in ctx.respond.call_args[0][0] - - # ── list command ───────────────────────────────────────────────── - - @pytest.mark.asyncio - async def test_list_no_roles(self, ctx, bot): - """Test listing when no dynamic roles are configured.""" - bot.role_manager.list_roles = AsyncMock(return_value=[]) - - cog = RoleAdminCog(bot) - await cog.list.callback(cog, ctx, category=None) - - ctx.respond.assert_awaited_once() - assert "No dynamic roles configured" in ctx.respond.call_args[0][0] - - @pytest.mark.asyncio - async def test_list_with_roles(self, ctx, bot): - """Test listing dynamic roles grouped by category.""" - mock_role_entry = MagicMock() - mock_role_entry.category = RoleCategory.RANK - mock_role_entry.key = "Omniscient" - mock_role_entry.discord_role_id = 1234 - mock_role_entry.display_name = "Omniscient Rank" - - guild_role = helpers.MockRole(id=1234, name="Omniscient") - ctx.guild.get_role = MagicMock(return_value=guild_role) - - bot.role_manager.list_roles = AsyncMock(return_value=[mock_role_entry]) - - cog = RoleAdminCog(bot) - await cog.list.callback(cog, ctx, category=None) - - ctx.respond.assert_awaited_once() - call_kwargs = ctx.respond.call_args[1] - embed = call_kwargs["embed"] - assert embed.title == "Dynamic Roles" - assert len(embed.fields) == 1 - assert embed.fields[0].name == "rank" - - @pytest.mark.asyncio - async def test_list_with_category_filter(self, ctx, bot): - """Test listing dynamic roles filtered by a specific category.""" - bot.role_manager.list_roles = AsyncMock(return_value=[]) - - cog = RoleAdminCog(bot) - await cog.list.callback(cog, ctx, category="rank") - - bot.role_manager.list_roles.assert_awaited_once_with(RoleCategory.RANK) - - @pytest.mark.asyncio - async def test_list_role_not_in_guild(self, ctx, bot): - """Test listing when a role ID does not exist in the guild (shows raw ID).""" - mock_role_entry = MagicMock() - mock_role_entry.category = RoleCategory.RANK - mock_role_entry.key = "Ghost" - mock_role_entry.discord_role_id = 99999 - mock_role_entry.display_name = "Ghost Rank" - - ctx.guild.get_role = MagicMock(return_value=None) - bot.role_manager.list_roles = AsyncMock(return_value=[mock_role_entry]) - - cog = RoleAdminCog(bot) - await cog.list.callback(cog, ctx, category=None) - - ctx.respond.assert_awaited_once() - call_kwargs = ctx.respond.call_args[1] - embed = call_kwargs["embed"] - # When guild role is not found, the raw ID should be shown - assert "99999" in embed.fields[0].value - - # ── reload command ─────────────────────────────────────────────── - - @pytest.mark.asyncio - async def test_reload(self, ctx, bot): - """Test the reload command calls role_manager.reload.""" - bot.role_manager.reload = AsyncMock() - - cog = RoleAdminCog(bot) - await cog.reload.callback(cog, ctx) - - bot.role_manager.reload.assert_awaited_once() - ctx.respond.assert_awaited_once() - assert "reloaded" in ctx.respond.call_args[0][0].lower() - - # ── setup ──────────────────────────────────────────────────────── - - def test_setup(self, bot): - """Test the setup function registers the cog.""" - role_admin.setup(bot) - bot.add_cog.assert_called_once()