From b85ce4875435083190e07b17ef8fd5ededed454f Mon Sep 17 00:00:00 2001 From: 0xRy4n Date: Tue, 2 Jun 2026 20:33:14 +0100 Subject: [PATCH 1/2] Add XP-system rank roles Add a mutually-exclusive Discord role per HTB XP tier (Beginner through Grandmaster), kept in sync via an XpRankChange webhook on the account platform. Reuses the existing role-swap machinery, mirroring the season rank handler including its error logging. - RoleCategory.XP_RANK enum member + Alembic ENUM migration - WebhookEvent.XP_RANK_CHANGE - RoleManager.get_xp_rank_role_id() - AccountHandler._handle_xp_rank_change() dispatch + handler - 7 XP_RANK seed rows - handler tests (assign-from-none, swap, unknown rank, dispatch) --- .../c3e7b1f9d2a4_add_xp_rank_role_category.py | 38 ++++++ scripts/seed_dynamic_roles.py | 8 ++ src/database/models/dynamic_role.py | 1 + src/services/role_manager.py | 4 + src/webhooks/handlers/account.py | 27 ++++ src/webhooks/types.py | 1 + tests/helpers.py | 3 + tests/src/webhooks/handlers/test_account.py | 120 ++++++++++++++++++ 8 files changed, 202 insertions(+) create mode 100644 alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py diff --git a/alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py b/alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py new file mode 100644 index 0000000..0f1f316 --- /dev/null +++ b/alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py @@ -0,0 +1,38 @@ +"""Add XP_RANK to dynamic_role category enum + +Revision ID: c3e7b1f9d2a4 +Revises: 9aa21aede2ec +Create Date: 2026-06-02 00:00:00.000000 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'c3e7b1f9d2a4' +down_revision = '9aa21aede2ec' +branch_labels = None +depends_on = None + +_BASE_VALUES = ( + 'RANK', 'SEASON', 'SUBSCRIPTION_LABS', 'SUBSCRIPTION_ACADEMY', + 'CREATOR', 'POSITION', 'ACADEMY_CERT', 'JOINABLE', +) + + +def upgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, 'XP_RANK', name='rolecategory'), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, 'XP_RANK', name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + existing_nullable=False, + ) diff --git a/scripts/seed_dynamic_roles.py b/scripts/seed_dynamic_roles.py index 712ce2e..9a135c1 100644 --- a/scripts/seed_dynamic_roles.py +++ b/scripts/seed_dynamic_roles.py @@ -54,6 +54,14 @@ ("SEASON_RUBY", RoleCategory.SEASON, "Ruby", "Ruby", {}), ("SEASON_SILVER", RoleCategory.SEASON, "Silver", "Silver", {}), ("SEASON_BRONZE", RoleCategory.SEASON, "Bronze", "Bronze", {}), + # XP-system ranks (one role per tier, grades I/II/III collapsed) + ("XP_BEGINNER", RoleCategory.XP_RANK, "Beginner", "Beginner", {}), + ("XP_APPRENTICE", RoleCategory.XP_RANK, "Apprentice", "Apprentice", {}), + ("XP_SKILLED", RoleCategory.XP_RANK, "Skilled", "Skilled", {}), + ("XP_PROFESSIONAL", RoleCategory.XP_RANK, "Professional", "Professional", {}), + ("XP_MASTER", RoleCategory.XP_RANK, "Master", "Master", {}), + ("XP_PRODIGY", RoleCategory.XP_RANK, "Prodigy", "Prodigy", {}), + ("XP_GRANDMASTER", RoleCategory.XP_RANK, "Grandmaster", "Grandmaster", {}), # Academy Certs (with cert_full_name and cert_integer_id) ("ACADEMY_CWES", RoleCategory.ACADEMY_CERT, "CWES", "Certified Web Exploitation Specialist", { "cert_full_name": "HTB Certified Web Exploitation Specialist", diff --git a/src/database/models/dynamic_role.py b/src/database/models/dynamic_role.py index bf1341d..f43a022 100644 --- a/src/database/models/dynamic_role.py +++ b/src/database/models/dynamic_role.py @@ -18,6 +18,7 @@ class RoleCategory(str, enum.Enum): POSITION = "position" ACADEMY_CERT = "academy_cert" JOINABLE = "joinable" + XP_RANK = "xp_rank" class DynamicRole(Base): diff --git a/src/services/role_manager.py b/src/services/role_manager.py index 33aa5f1..7a4b4c5 100644 --- a/src/services/role_manager.py +++ b/src/services/role_manager.py @@ -168,6 +168,10 @@ def get_season_role_id(self, tier: str) -> Optional[int]: """Get season tier role (Holo, Platinum, etc.).""" return self.get_role_id(RoleCategory.SEASON.value, tier) + def get_xp_rank_role_id(self, rank_name: str) -> Optional[int]: + """Get XP-system rank role by tier name (Beginner, Apprentice, ...).""" + return self.get_role_id(RoleCategory.XP_RANK.value, rank_name) + # ── Cross-category lookup ──────────────────────────────────────── def get_post_or_rank(self, what: str) -> Optional[int]: diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 517f4a3..6548ddd 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -26,6 +26,8 @@ async def handle(self, body: WebhookBody, bot: Bot): return await self._handle_account_banned(body, bot) elif body.event == WebhookEvent.NAME_CHANGE: return await self._handle_name_change(body, bot) + elif body.event == WebhookEvent.XP_RANK_CHANGE: + return await self._handle_xp_rank_change(body, bot) else: raise ValueError(f"Invalid event: {body.event}") @@ -126,6 +128,31 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: return self.success() + async def _handle_xp_rank_change(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the XP-system rank change event. + """ + discord_id, account_id = self.validate_common_properties(body) + xp_rank = self.validate_property( + self.get_property_or_trait(body, "xp_rank"), "xp_rank" + ) + role_id = bot.role_manager.get_xp_rank_role_id(xp_rank) + if not role_id: + err = ValueError(f"Cannot find role for XP rank '{xp_rank}'") + self.logger.error( + err, + extra={ + "account_id": account_id, + "discord_id": discord_id, + "xp_rank": xp_rank, + }, + ) + raise err + member = await self.get_guild_member(discord_id, bot) + role_group = bot.role_manager.get_group_ids("xp_rank") + await self.swap_role_in_group(member, role_id, role_group, bot) + return self.success() + async def _handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account deleted event. diff --git a/src/webhooks/types.py b/src/webhooks/types.py index e260412..ed594e0 100644 --- a/src/webhooks/types.py +++ b/src/webhooks/types.py @@ -14,6 +14,7 @@ class WebhookEvent(Enum): SUBSCRIPTION_CHANGE = "SubscriptionChange" NAME_CHANGE = "NameChange" SEASON_RANK_CHANGE = "SeasonRankChange" + XP_RANK_CHANGE = "XpRankChange" class Platform(Enum): diff --git a/tests/helpers.py b/tests/helpers.py index 9a7f775..c550f6f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -301,6 +301,9 @@ def get_rank_role_id(self, rank_name): def get_season_role_id(self, tier): return None + def get_xp_rank_role_id(self, rank_name): + return None + def get_post_or_rank(self, what): return None diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py index e18cf7e..ff94b28 100644 --- a/tests/src/webhooks/handlers/test_account.py +++ b/tests/src/webhooks/handlers/test_account.py @@ -11,6 +11,14 @@ from tests import helpers +def _make_role_manager(**overrides): + """Create a MockRoleManager with optional method overrides.""" + rm = helpers.MockRoleManager() + for attr, val in overrides.items(): + setattr(rm, attr, val if callable(val) else lambda *a, v=val, **kw: v) + return rm + + class TestAccountHandler: """Test the `AccountHandler` class.""" @@ -481,6 +489,118 @@ async def test_handle_account_banned_success(self, bot): mock_log.assert_called() assert result == handler.success() + @pytest.mark.asyncio + async def test_handle_xp_rank_change_event(self, bot): + """Test handle method routes XP_RANK_CHANGE event correctly.""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={"discord_id": 123456789, "account_id": 987654321, "xp_rank": "Skilled"}, + traits={}, + ) + + with patch.object( + handler, "_handle_xp_rank_change", new_callable=AsyncMock + ) as mock_handle: + await handler.handle(body, bot) + mock_handle.assert_called_once_with(body, bot) + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_assign_from_none(self, bot): + """Member with no XP role gets the new role added, nothing removed.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + xp_rank = "Skilled" + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={"discord_id": discord_id, "account_id": account_id, "xp_rank": xp_rank}, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(return_value=xp_rank) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + role_555 = MagicMock(id=555) + role_666 = MagicMock(id=666) + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_group_ids=lambda cat: [555, 666], + ) + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: {555: role_555, 666: role_666}.get(rid) + bot.guilds = [mock_guild] + + result = await handler._handle_xp_rank_change(body, bot) + mock_member.add_roles.assert_awaited_once_with(role_555, atomic=True) + mock_member.remove_roles.assert_not_awaited() + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_swap_existing(self, bot): + """Member with another XP role has it removed and the new one added.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + xp_rank = "Skilled" + role_555 = MagicMock(id=555) + role_666 = MagicMock(id=666) + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [role_666] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={"discord_id": discord_id, "account_id": account_id, "xp_rank": xp_rank}, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(return_value=xp_rank) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_group_ids=lambda cat: [555, 666], + ) + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: {555: role_555, 666: role_666}.get(rid) + bot.guilds = [mock_guild] + + result = await handler._handle_xp_rank_change(body, bot) + mock_member.remove_roles.assert_awaited_once_with(role_666, atomic=True) + mock_member.add_roles.assert_awaited_once_with(role_555, atomic=True) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_unknown_rank(self, bot): + """Unknown XP rank (no role configured) raises ValueError.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + xp_rank = "Nonexistent" + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={"discord_id": discord_id, "account_id": account_id, "xp_rank": xp_rank}, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(return_value=xp_rank) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + bot.role_manager = _make_role_manager(get_xp_rank_role_id=lambda rank: None) + + with pytest.raises(ValueError, match="Cannot find role for"): + await handler._handle_xp_rank_change(body, bot) + @pytest.mark.asyncio async def test_handle_account_banned_member_not_found(self, bot): """Test account banned event when member is not found in guild.""" From 7c80db4d43d617a0fdf114df46609d8903e1e8b6 Mon Sep 17 00:00:00 2001 From: 0xRy4n Date: Tue, 2 Jun 2026 20:52:53 +0100 Subject: [PATCH 2/2] Add XP-system grade roles Add a second mutually-exclusive Discord role group for XP grades (I/II/III) on the same XpRankChange webhook. Grade roles are independent of tier roles, so a member can hold both a tier role and a grade role simultaneously (e.g. Grandmaster + Grade III). - RoleCategory.XP_GRADE enum member + Alembic ENUM migration - RoleManager.get_xp_grade_role_id() - _handle_xp_rank_change() now swaps both the xp_rank and xp_grade groups - 3 XP_GRADE seed rows - handler tests for grade assign/swap, tier/grade independence, unknown grade --- ...d4f8c2a6e1b7_add_xp_grade_role_category.py | 38 ++++ scripts/seed_dynamic_roles.py | 4 + src/database/models/dynamic_role.py | 1 + src/services/role_manager.py | 4 + src/webhooks/handlers/account.py | 38 ++-- tests/helpers.py | 3 + tests/src/webhooks/handlers/test_account.py | 165 +++++++++++++++--- 7 files changed, 214 insertions(+), 39 deletions(-) create mode 100644 alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py diff --git a/alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py b/alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py new file mode 100644 index 0000000..edbd413 --- /dev/null +++ b/alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py @@ -0,0 +1,38 @@ +"""Add XP_GRADE to dynamic_role category enum + +Revision ID: d4f8c2a6e1b7 +Revises: c3e7b1f9d2a4 +Create Date: 2026-06-02 00:00:01.000000 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'd4f8c2a6e1b7' +down_revision = 'c3e7b1f9d2a4' +branch_labels = None +depends_on = None + +_BASE_VALUES = ( + 'RANK', 'SEASON', 'SUBSCRIPTION_LABS', 'SUBSCRIPTION_ACADEMY', + 'CREATOR', 'POSITION', 'ACADEMY_CERT', 'JOINABLE', 'XP_RANK', +) + + +def upgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, 'XP_GRADE', name='rolecategory'), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, 'XP_GRADE', name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + existing_nullable=False, + ) diff --git a/scripts/seed_dynamic_roles.py b/scripts/seed_dynamic_roles.py index 9a135c1..ecc24b3 100644 --- a/scripts/seed_dynamic_roles.py +++ b/scripts/seed_dynamic_roles.py @@ -62,6 +62,10 @@ ("XP_MASTER", RoleCategory.XP_RANK, "Master", "Master", {}), ("XP_PRODIGY", RoleCategory.XP_RANK, "Prodigy", "Prodigy", {}), ("XP_GRANDMASTER", RoleCategory.XP_RANK, "Grandmaster", "Grandmaster", {}), + # XP-system grades (mutually exclusive with each other, independent of tiers) + ("XP_GRADE_I", RoleCategory.XP_GRADE, "I", "Grade I", {}), + ("XP_GRADE_II", RoleCategory.XP_GRADE, "II", "Grade II", {}), + ("XP_GRADE_III", RoleCategory.XP_GRADE, "III", "Grade III", {}), # Academy Certs (with cert_full_name and cert_integer_id) ("ACADEMY_CWES", RoleCategory.ACADEMY_CERT, "CWES", "Certified Web Exploitation Specialist", { "cert_full_name": "HTB Certified Web Exploitation Specialist", diff --git a/src/database/models/dynamic_role.py b/src/database/models/dynamic_role.py index f43a022..eaa0bb8 100644 --- a/src/database/models/dynamic_role.py +++ b/src/database/models/dynamic_role.py @@ -19,6 +19,7 @@ class RoleCategory(str, enum.Enum): ACADEMY_CERT = "academy_cert" JOINABLE = "joinable" XP_RANK = "xp_rank" + XP_GRADE = "xp_grade" class DynamicRole(Base): diff --git a/src/services/role_manager.py b/src/services/role_manager.py index 7a4b4c5..07392dd 100644 --- a/src/services/role_manager.py +++ b/src/services/role_manager.py @@ -172,6 +172,10 @@ def get_xp_rank_role_id(self, rank_name: str) -> Optional[int]: """Get XP-system rank role by tier name (Beginner, Apprentice, ...).""" return self.get_role_id(RoleCategory.XP_RANK.value, rank_name) + def get_xp_grade_role_id(self, grade: str) -> Optional[int]: + """Get XP-system grade role by grade (I, II, III).""" + return self.get_role_id(RoleCategory.XP_GRADE.value, grade) + # ── Cross-category lookup ──────────────────────────────────────── def get_post_or_rank(self, what: str) -> Optional[int]: diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 6548ddd..fd758c8 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -131,26 +131,40 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: async def _handle_xp_rank_change(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the XP-system rank change event. + + Assigns the XP tier role and the XP grade role. Tier roles are mutually + exclusive with each other and grade roles are mutually exclusive with + each other, but the two groups are independent: a member can hold one + tier role and one grade role at the same time. """ discord_id, account_id = self.validate_common_properties(body) + extra = {"account_id": account_id, "discord_id": discord_id} + xp_rank = self.validate_property( self.get_property_or_trait(body, "xp_rank"), "xp_rank" ) - role_id = bot.role_manager.get_xp_rank_role_id(xp_rank) - if not role_id: + rank_role_id = bot.role_manager.get_xp_rank_role_id(xp_rank) + if not rank_role_id: err = ValueError(f"Cannot find role for XP rank '{xp_rank}'") - self.logger.error( - err, - extra={ - "account_id": account_id, - "discord_id": discord_id, - "xp_rank": xp_rank, - }, - ) + self.logger.error(err, extra={**extra, "xp_rank": xp_rank}) + raise err + + xp_grade = self.validate_property( + self.get_property_or_trait(body, "xp_grade"), "xp_grade" + ) + grade_role_id = bot.role_manager.get_xp_grade_role_id(xp_grade) + if not grade_role_id: + err = ValueError(f"Cannot find role for XP grade '{xp_grade}'") + self.logger.error(err, extra={**extra, "xp_grade": xp_grade}) raise err + member = await self.get_guild_member(discord_id, bot) - role_group = bot.role_manager.get_group_ids("xp_rank") - await self.swap_role_in_group(member, role_id, role_group, bot) + await self.swap_role_in_group( + member, rank_role_id, bot.role_manager.get_group_ids("xp_rank"), bot + ) + await self.swap_role_in_group( + member, grade_role_id, bot.role_manager.get_group_ids("xp_grade"), bot + ) return self.success() async def _handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: diff --git a/tests/helpers.py b/tests/helpers.py index c550f6f..d8ff08b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -304,6 +304,9 @@ def get_season_role_id(self, tier): def get_xp_rank_role_id(self, rank_name): return None + def get_xp_grade_role_id(self, grade): + return None + def get_post_or_rank(self, what): return None diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py index ff94b28..9af977b 100644 --- a/tests/src/webhooks/handlers/test_account.py +++ b/tests/src/webhooks/handlers/test_account.py @@ -496,7 +496,12 @@ async def test_handle_xp_rank_change_event(self, bot): body = WebhookBody( platform=Platform.ACCOUNT, event=WebhookEvent.XP_RANK_CHANGE, - properties={"discord_id": 123456789, "account_id": 987654321, "xp_rank": "Skilled"}, + properties={ + "discord_id": 123456789, + "account_id": 987654321, + "xp_rank": "Skilled", + "xp_grade": "III", + }, traits={}, ) @@ -508,11 +513,10 @@ async def test_handle_xp_rank_change_event(self, bot): @pytest.mark.asyncio async def test_handle_xp_rank_change_assign_from_none(self, bot): - """Member with no XP role gets the new role added, nothing removed.""" + """Member with no XP roles gets both a tier and a grade role added.""" handler = AccountHandler() discord_id = 123456789 account_id = 987654321 - xp_rank = "Skilled" mock_member = helpers.MockMember(id=discord_id) mock_member.roles = [] mock_member.add_roles = AsyncMock() @@ -520,62 +524,132 @@ async def test_handle_xp_rank_change_assign_from_none(self, bot): body = WebhookBody( platform=Platform.ACCOUNT, event=WebhookEvent.XP_RANK_CHANGE, - properties={"discord_id": discord_id, "account_id": account_id, "xp_rank": xp_rank}, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "III", + }, traits={}, ) handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) - handler.validate_property = MagicMock(return_value=xp_rank) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) handler.get_guild_member = AsyncMock(return_value=mock_member) - role_555 = MagicMock(id=555) - role_666 = MagicMock(id=666) + role_tier = MagicMock(id=555) + role_tier_other = MagicMock(id=666) + role_grade = MagicMock(id=30) + role_grade_other = MagicMock(id=20) + roles = {555: role_tier, 666: role_tier_other, 20: role_grade_other, 30: role_grade} + groups = {"xp_rank": [555, 666], "xp_grade": [20, 30]} bot.role_manager = _make_role_manager( get_xp_rank_role_id=lambda rank: 555, - get_group_ids=lambda cat: [555, 666], + get_xp_grade_role_id=lambda grade: 30, + get_group_ids=lambda cat: groups[cat], ) mock_guild = helpers.MockGuild(id=1) - mock_guild.get_role.side_effect = lambda rid: {555: role_555, 666: role_666}.get(rid) + mock_guild.get_role.side_effect = lambda rid: roles.get(rid) bot.guilds = [mock_guild] result = await handler._handle_xp_rank_change(body, bot) - mock_member.add_roles.assert_awaited_once_with(role_555, atomic=True) + assert mock_member.add_roles.await_count == 2 + mock_member.add_roles.assert_any_await(role_tier, atomic=True) + mock_member.add_roles.assert_any_await(role_grade, atomic=True) mock_member.remove_roles.assert_not_awaited() assert result == handler.success() @pytest.mark.asyncio - async def test_handle_xp_rank_change_swap_existing(self, bot): - """Member with another XP role has it removed and the new one added.""" + async def test_handle_xp_rank_change_swaps_both_groups(self, bot): + """Existing tier and grade roles are each swapped within their own group.""" handler = AccountHandler() discord_id = 123456789 account_id = 987654321 - xp_rank = "Skilled" - role_555 = MagicMock(id=555) - role_666 = MagicMock(id=666) + role_tier = MagicMock(id=555) + role_tier_old = MagicMock(id=666) + role_grade = MagicMock(id=30) + role_grade_old = MagicMock(id=20) mock_member = helpers.MockMember(id=discord_id) - mock_member.roles = [role_666] + mock_member.roles = [role_tier_old, role_grade_old] mock_member.add_roles = AsyncMock() mock_member.remove_roles = AsyncMock() body = WebhookBody( platform=Platform.ACCOUNT, event=WebhookEvent.XP_RANK_CHANGE, - properties={"discord_id": discord_id, "account_id": account_id, "xp_rank": xp_rank}, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "III", + }, traits={}, ) handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) - handler.validate_property = MagicMock(return_value=xp_rank) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) handler.get_guild_member = AsyncMock(return_value=mock_member) + roles = {555: role_tier, 666: role_tier_old, 20: role_grade_old, 30: role_grade} + groups = {"xp_rank": [555, 666], "xp_grade": [20, 30]} bot.role_manager = _make_role_manager( get_xp_rank_role_id=lambda rank: 555, - get_group_ids=lambda cat: [555, 666], + get_xp_grade_role_id=lambda grade: 30, + get_group_ids=lambda cat: groups[cat], ) mock_guild = helpers.MockGuild(id=1) - mock_guild.get_role.side_effect = lambda rid: {555: role_555, 666: role_666}.get(rid) + mock_guild.get_role.side_effect = lambda rid: roles.get(rid) bot.guilds = [mock_guild] result = await handler._handle_xp_rank_change(body, bot) - mock_member.remove_roles.assert_awaited_once_with(role_666, atomic=True) - mock_member.add_roles.assert_awaited_once_with(role_555, atomic=True) + mock_member.remove_roles.assert_any_await(role_tier_old, atomic=True) + mock_member.remove_roles.assert_any_await(role_grade_old, atomic=True) + mock_member.add_roles.assert_any_await(role_tier, atomic=True) + mock_member.add_roles.assert_any_await(role_grade, atomic=True) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_grade_independent_of_tier(self, bot): + """Changing only the grade leaves the existing (unchanged) tier role intact.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + role_tier = MagicMock(id=555) + role_tier_other = MagicMock(id=666) + role_grade = MagicMock(id=30) + role_grade_old = MagicMock(id=20) + mock_member = helpers.MockMember(id=discord_id) + # Member already holds the target tier role and the old grade role. + mock_member.roles = [role_tier, role_grade_old] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "III", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + roles = {555: role_tier, 666: role_tier_other, 20: role_grade_old, 30: role_grade} + groups = {"xp_rank": [555, 666], "xp_grade": [20, 30]} + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_xp_grade_role_id=lambda grade: 30, + get_group_ids=lambda cat: groups[cat], + ) + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: roles.get(rid) + bot.guilds = [mock_guild] + + result = await handler._handle_xp_rank_change(body, bot) + # Tier unchanged: no add/remove of the tier role. + mock_member.remove_roles.assert_awaited_once_with(role_grade_old, atomic=True) + mock_member.add_roles.assert_awaited_once_with(role_grade, atomic=True) assert result == handler.success() @pytest.mark.asyncio @@ -584,21 +658,58 @@ async def test_handle_xp_rank_change_unknown_rank(self, bot): handler = AccountHandler() discord_id = 123456789 account_id = 987654321 - xp_rank = "Nonexistent" mock_member = helpers.MockMember(id=discord_id) body = WebhookBody( platform=Platform.ACCOUNT, event=WebhookEvent.XP_RANK_CHANGE, - properties={"discord_id": discord_id, "account_id": account_id, "xp_rank": xp_rank}, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Nonexistent", + "xp_grade": "III", + }, traits={}, ) handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) - handler.validate_property = MagicMock(return_value=xp_rank) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) handler.get_guild_member = AsyncMock(return_value=mock_member) - bot.role_manager = _make_role_manager(get_xp_rank_role_id=lambda rank: None) + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: None, + get_xp_grade_role_id=lambda grade: 30, + ) + + with pytest.raises(ValueError, match="Cannot find role for XP rank"): + await handler._handle_xp_rank_change(body, bot) + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_unknown_grade(self, bot): + """Unknown XP grade (no role configured) raises ValueError.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "Nonexistent", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_xp_grade_role_id=lambda grade: None, + ) - with pytest.raises(ValueError, match="Cannot find role for"): + with pytest.raises(ValueError, match="Cannot find role for XP grade"): await handler._handle_xp_rank_change(body, bot) @pytest.mark.asyncio