From abe98012e57c8c7e9b6aebb0ad134c548f4a361b Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Fri, 27 Mar 2026 17:38:48 -0400 Subject: [PATCH] fix(setup): alignes with standards --- capy_discord/exts/setup/_views.py | 15 +- capy_discord/exts/setup/setup.py | 270 +++++++++++++++++--------- tests/capy_discord/exts/test_setup.py | 251 +++++++++++++++++++++++- 3 files changed, 431 insertions(+), 105 deletions(-) diff --git a/capy_discord/exts/setup/_views.py b/capy_discord/exts/setup/_views.py index ff7f9b9..cd4d438 100644 --- a/capy_discord/exts/setup/_views.py +++ b/capy_discord/exts/setup/_views.py @@ -19,13 +19,15 @@ class VerifyView(BaseView): def __init__( self, *, + attempt_id: int, target_user_id: int, - on_accept: Callable[[discord.Interaction, int], Awaitable[None]], + on_accept: Callable[[discord.Interaction, int, int], Awaitable[bool]], on_timeout_callback: Callable[[int], Awaitable[None]], timeout: float = 1800, ) -> None: """Initialize a verification view tied to one target user.""" super().__init__(timeout=timeout) + self.attempt_id = attempt_id self.target_user_id = target_user_id self._on_accept = on_accept self._on_timeout_callback = on_timeout_callback @@ -40,11 +42,12 @@ async def accept(self, interaction: discord.Interaction, _button: ui.Button) -> ) return - await self._on_accept(interaction, self.target_user_id) - self.disable_all_items() - if self.message: - await self.message.edit(view=self) - self.stop() + completed = await self._on_accept(interaction, self.target_user_id, self.attempt_id) + if completed: + self.disable_all_items() + if self.message: + await self.message.edit(view=self) + self.stop() async def on_timeout(self) -> None: """Mark state timeout and disable all controls when view expires.""" diff --git a/capy_discord/exts/setup/setup.py b/capy_discord/exts/setup/setup.py index ccba084..e61c9c6 100644 --- a/capy_discord/exts/setup/setup.py +++ b/capy_discord/exts/setup/setup.py @@ -28,11 +28,9 @@ def utc_now() -> datetime: return datetime.now(ZoneInfo("UTC")) -class Setup(commands.Cog): +class Setup(commands.GroupCog, group_name="setup", group_description="Configure onboarding and server setup"): """Cog that manages guild setup and member onboarding.""" - setup = app_commands.Group(name="setup", description="Configure onboarding and server setup") - def __init__(self, bot: commands.Bot) -> None: """Initialize in-memory stores for setup and user onboarding state.""" self.bot = bot @@ -81,10 +79,10 @@ def _cancel_grace_task(self, guild_id: int, user_id: int) -> None: if task is not None and not task.done(): task.cancel() - def _schedule_grace_period_check(self, guild_id: int, user_id: int) -> None: + def _schedule_grace_period_check(self, guild_id: int, user_id: int, attempt_id: int) -> None: """Start the grace-period enforcement task for a member.""" self._cancel_grace_task(guild_id, user_id) - task = asyncio.create_task(self._enforce_grace_period(guild_id, user_id)) + task = asyncio.create_task(self._enforce_grace_period(guild_id, user_id, attempt_id)) self._grace_tasks[self._state_key(guild_id, user_id)] = task def _get_bot_member(self, guild: discord.Guild) -> discord.Member | None: @@ -206,28 +204,110 @@ async def _send_log_message(self, guild: discord.Guild, config: GuildSetupConfig except discord.HTTPException as exc: self.log.warning("Failed to send onboarding log message in guild %s: %s", guild.id, exc) - async def _mark_pending(self, guild_id: int, user_id: int) -> None: + async def _mark_pending(self, guild_id: int, user_id: int) -> int: """Mark user state as pending and increment attempt count.""" state = self._get_user_state(guild_id, user_id) state.status = "pending" state.started_at_utc = utc_now() state.completed_at_utc = None state.attempts += 1 + return state.attempts - async def _mark_timed_out(self, guild_id: int, user_id: int) -> None: - """Reset pending state to new when verification view times out.""" + def _reset_onboarding_state(self, guild_id: int, user_id: int, *, attempt_id: int | None = None) -> bool: + """Reset a pending onboarding attempt back to a clean retriable state.""" state = self._get_user_state(guild_id, user_id) - if state.status == "pending": - state.status = "new" - self.log.info("Setup timed out for user %s in guild %s", user_id, guild_id) - guild = self.bot.get_guild(guild_id) - if guild is not None: - config = self._ensure_setup(guild_id) - member = guild.get_member(user_id) - member_text = f"{member.mention} ({member.id})" if member is not None else f"user {user_id}" - await self._send_log_message(guild, config, f"🟠 Onboarding timed out for {member_text}") + if state.status != "pending": + return False + if attempt_id is not None and state.attempts != attempt_id: + return False + + state.status = "new" + state.started_at_utc = None + state.completed_at_utc = None + return True + + def _render_onboarding_message( + self, + member: discord.Member, + config: GuildSetupConfig, + *, + is_retry: bool = False, + ) -> str: + """Render the onboarding prompt content for a member.""" + template = ( + config.onboarding_message_template + or "Welcome {user}! Please review {rules} and click **Accept Rules** below to complete onboarding." + ) + rendered = template.replace("{user}", member.mention).replace( + "{rules}", + config.rules_location or "the server rules", + ) + if not is_retry: + return rendered + + return ( + f"{member.mention} your previous verification button timed out. " + "Here is a fresh one so you can finish onboarding.\n\n" + f"{rendered}" + ) + + async def _send_verification_prompt(self, member: discord.Member, *, is_retry: bool = False) -> bool: + """Post a verification prompt and start the matching grace-period task.""" + config = self._ensure_setup(member.guild.id) + if not config.enabled or config.welcome_channel_id is None or config.member_role_id is None: + return False + + welcome_channel = member.guild.get_channel(config.welcome_channel_id) + if not isinstance(welcome_channel, discord.TextChannel): + return False + + attempt_id = await self._mark_pending(member.guild.id, member.id) + view = VerifyView( + attempt_id=attempt_id, + target_user_id=member.id, + on_accept=self._handle_accept, + on_timeout_callback=partial(self._handle_verification_timeout, member.guild.id, attempt_id), + timeout=1800, + ) + + sent = await welcome_channel.send( + self._render_onboarding_message(member, config, is_retry=is_retry), + allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), + view=view, + ) + view.message = sent + self._schedule_grace_period_check(member.guild.id, member.id, attempt_id) + return True + + async def _handle_verification_timeout(self, guild_id: int, attempt_id: int, user_id: int) -> None: + """Reset stale timeout state and automatically repost a fresh verification prompt.""" + if not self._reset_onboarding_state(guild_id, user_id, attempt_id=attempt_id): + return + + self._cancel_grace_task(guild_id, user_id) + self.log.info("Setup timed out for user %s in guild %s", user_id, guild_id) + + guild = self.bot.get_guild(guild_id) + if guild is None: + return + + config = self._ensure_setup(guild_id) + member = guild.get_member(user_id) + member_text = f"{member.mention} ({member.id})" if member is not None else f"user {user_id}" + await self._send_log_message(guild, config, f"🟠 Onboarding timed out for {member_text}") + + if member is None: + return + + reposted = await self._send_verification_prompt(member, is_retry=True) + if reposted: + await self._send_log_message( + guild, + config, + f"🔁 Reposted verification prompt for {member.mention} ({member.id}) after timeout.", + ) - async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: + async def _enforce_grace_period(self, guild_id: int, user_id: int, attempt_id: int) -> None: """Remove unverified members after the configured grace period.""" try: config = self._ensure_setup(guild_id) @@ -235,7 +315,12 @@ async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: config = self._ensure_setup(guild_id) state = self._get_user_state(guild_id, user_id) - if not config.auto_kick_unverified or state.status == "verified" or state.started_at_utc is None: + if ( + not config.auto_kick_unverified + or state.status == "verified" + or state.started_at_utc is None + or state.attempts != attempt_id + ): return deadline = state.started_at_utc + timedelta(hours=config.grace_period_hours) @@ -298,48 +383,63 @@ async def _enforce_grace_period(self, guild_id: int, user_id: int) -> None: if self._grace_tasks.get(key) is asyncio.current_task(): self._grace_tasks.pop(key, None) - async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int) -> None: - """Handle onboarding acceptance and assign member role.""" - guild = interaction.guild - if guild is None: - await interaction.response.send_message("This action must be used in a server.", ephemeral=True) - return - - config = self._ensure_setup(guild.id) - if config.member_role_id is None: - await interaction.response.send_message( - "Setup incomplete: configure a verification member role with `/setup roles`.", - ephemeral=True, - ) - return - - role = guild.get_role(config.member_role_id) - if role is None: - await interaction.response.send_message( - "Configured member role no longer exists. Please reconfigure `/setup roles`.", - ephemeral=True, - ) - return + def _resolve_accept_context( + self, + guild: discord.Guild | None, + target_user_id: int, + attempt_id: int, + ) -> tuple[str | None, GuildSetupConfig | None, discord.Member | None, discord.Role | None]: + """Validate an onboarding acceptance attempt and return the resolved entities.""" + failure_message: str | None = None + config: GuildSetupConfig | None = None + member: discord.Member | None = None + role: discord.Role | None = None - member = guild.get_member(target_user_id) - if member is None: - await interaction.response.send_message("Could not find that member in this server.", ephemeral=True) - return + if guild is None: + failure_message = "This action must be used in a server." + else: + state = self._get_user_state(guild.id, target_user_id) + if state.status != "pending" or state.attempts != attempt_id: + failure_message = "This verification prompt has expired. Use the newest button in the welcome channel." + else: + config = self._ensure_setup(guild.id) + if config.member_role_id is None: + failure_message = "Setup incomplete: configure a verification member role with `/setup roles`." + else: + role = guild.get_role(config.member_role_id) + if role is None: + failure_message = "Configured member role no longer exists. Please reconfigure `/setup roles`." + else: + member = guild.get_member(target_user_id) + if member is None: + failure_message = "Could not find that member in this server." + else: + bot_member = self._get_bot_member(guild) + if bot_member is None or not bot_member.guild_permissions.manage_roles: + failure_message = "I need **Manage Roles** permission to finish onboarding." + elif bot_member.top_role <= role: + failure_message = ( + "I cannot assign that role because it is higher than or equal to my top role." + ) + + return failure_message, config, member, role + + async def _handle_accept(self, interaction: discord.Interaction, target_user_id: int, attempt_id: int) -> bool: + """Handle onboarding acceptance and assign member role.""" + failure_message, config, member, role = self._resolve_accept_context( + interaction.guild, + target_user_id, + attempt_id, + ) - bot_member = self._get_bot_member(guild) - if bot_member is None or not bot_member.guild_permissions.manage_roles: - await interaction.response.send_message( - "I need **Manage Roles** permission to finish onboarding.", - ephemeral=True, - ) - return + if failure_message is not None: + await interaction.response.send_message(failure_message, ephemeral=True) + return False - if bot_member.top_role <= role: - await interaction.response.send_message( - "I cannot assign that role because it is higher than or equal to my top role.", - ephemeral=True, - ) - return + guild = interaction.guild + if guild is None or config is None or member is None or role is None: + await interaction.response.send_message("This verification prompt is no longer valid.", ephemeral=True) + return False if role not in member.roles: await member.add_roles(role, reason="Completed onboarding rule acceptance") @@ -351,6 +451,7 @@ async def _handle_accept(self, interaction: discord.Interaction, target_user_id: await interaction.response.send_message("✅ Verification complete. You now have member access.", ephemeral=True) await self._send_log_message(guild, config, f"✅ Verified {member.mention} ({member.id})") + return True @commands.Cog.listener() async def on_guild_join(self, guild: discord.Guild) -> None: @@ -390,31 +491,14 @@ async def on_member_join(self, member: discord.Member) -> None: ) return - await self._mark_pending(member.guild.id, member.id) - - template = ( - config.onboarding_message_template - or "Welcome {user}! Please review {rules} and click **Accept Rules** below to complete onboarding." - ) - rendered = template.replace("{user}", member.mention).replace( - "{rules}", - config.rules_location or "the server rules", - ) - - view = VerifyView( - target_user_id=member.id, - on_accept=self._handle_accept, - on_timeout_callback=partial(self._mark_timed_out, member.guild.id), - timeout=1800, - ) - - sent = await welcome_channel.send( - rendered, - allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False), - view=view, - ) - view.message = sent - self._schedule_grace_period_check(member.guild.id, member.id) + posted = await self._send_verification_prompt(member) + if not posted: + self.log.info( + "Could not post onboarding prompt for member %s in guild %s after initial validation.", + member.id, + member.guild.id, + ) + return if config.welcome_dm_enabled: try: @@ -430,9 +514,9 @@ async def on_member_join(self, member: discord.Member) -> None: f"🟡 Onboarding started for {member.mention} ({member.id})", ) - @setup.command(name="summary", description="Show current setup values and missing required items") + @app_commands.command(name="summary", description="Show current setup values and missing required items") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) async def setup_summary(self, interaction: discord.Interaction) -> None: """Return a summary of setup state for this guild.""" if interaction.guild is None: @@ -474,9 +558,9 @@ async def setup_summary(self, interaction: discord.Interaction) -> None: await interaction.response.send_message("\n".join(lines), ephemeral=True) - @setup.command(name="roles", description="Set trusted admin/mod roles and verification member role") + @app_commands.command(name="roles", description="Set trusted admin/mod roles and verification member role") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( admin_roles="Role mentions or IDs (space/comma separated)", moderator_roles="Role mentions or IDs (space/comma separated)", @@ -505,9 +589,9 @@ async def setup_roles( await interaction.response.send_message("✅ Setup roles updated.", ephemeral=True) - @setup.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") + @app_commands.command(name="channels", description="Set channels used by logs, announcements, welcome, and support") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( log_channel="Channel for mod/automod/error logs", announcement_channel="Channel for server announcements", @@ -540,9 +624,9 @@ async def setup_channels( await interaction.response.send_message("✅ Setup channels updated.", ephemeral=True) - @setup.command(name="config", description="Set onboarding flow behavior") + @app_commands.command(name="config", description="Set onboarding flow behavior") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( enabled="Enable or disable onboarding for this guild", welcome_dm_enabled="Send DM hint in addition to welcome channel message", @@ -587,9 +671,9 @@ async def setup_onboarding( # noqa: PLR0913 await interaction.response.send_message("✅ Onboarding settings updated.", ephemeral=True) - @setup.command(name="reset", description="Reset setup and onboarding state for this guild") + @app_commands.command(name="reset", description="Reset setup and onboarding state for this guild") @app_commands.guild_only() - # @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) async def setup_reset(self, interaction: discord.Interaction) -> None: """Clear setup and user onboarding state for this guild.""" if interaction.guild is None: diff --git a/tests/capy_discord/exts/test_setup.py b/tests/capy_discord/exts/test_setup.py index 15fba8b..c8a61e5 100644 --- a/tests/capy_discord/exts/test_setup.py +++ b/tests/capy_discord/exts/test_setup.py @@ -4,6 +4,7 @@ import discord import pytest +from discord import app_commands from discord.ext import commands from capy_discord.exts.setup.setup import Onboarding, utc_now @@ -25,6 +26,13 @@ def _perm(view: bool, send: bool = False): return SimpleNamespace(view_channel=view, send_messages=send) +def _interaction_with_permissions(*, manage_guild: bool) -> MagicMock: + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = MagicMock(spec=discord.Guild) + interaction.permissions = discord.Permissions(manage_guild=manage_guild) + return interaction + + @pytest.mark.asyncio async def test_on_guild_join_posts_setup_message_to_first_public_channel(cog): guild = MagicMock(spec=discord.Guild) @@ -98,6 +106,7 @@ async def test_on_member_join_sets_pending_and_sends_welcome(cog): member.mention = "<@300>" member.guild = guild member.send = AsyncMock() + cog._schedule_grace_period_check = MagicMock() await cog.on_member_join(member) @@ -107,6 +116,21 @@ async def test_on_member_join_sets_pending_and_sends_welcome(cog): welcome_channel.send.assert_called_once() assert "Accept Rules" in welcome_channel.send.call_args.args[0] assert "view" in welcome_channel.send.call_args.kwargs + cog._schedule_grace_period_check.assert_called_once_with(guild.id, member.id, 1) + + +@pytest.mark.parametrize( + "command_name", + ["setup_summary", "setup_roles", "setup_channels", "setup_onboarding", "setup_reset"], +) +@pytest.mark.asyncio +async def test_setup_commands_require_manage_guild_for_non_managers(cog, command_name): + interaction = _interaction_with_permissions(manage_guild=False) + + with pytest.raises(app_commands.MissingPermissions) as exc_info: + await getattr(cog, command_name)._check_can_run(interaction) + + assert exc_info.value.missing_permissions == ["manage_guild"] @pytest.mark.asyncio @@ -123,7 +147,8 @@ async def test_setup_roles_updates_config(cog): member_role = MagicMock(spec=discord.Role) member_role.id = 50 - guild.get_role.side_effect = lambda role_id: {1: role_1, 2: role_2, 3: role_3, 50: member_role}.get(role_id) + roles = {1: role_1, 2: role_2, 3: role_3, 50: member_role} + guild.get_role.side_effect = roles.get interaction = MagicMock(spec=discord.Interaction) interaction.guild = guild @@ -190,24 +215,34 @@ async def test_handle_accept_assigns_role_and_marks_verified(cog): bot_member = MagicMock() bot_member.guild_permissions = SimpleNamespace(manage_roles=True) - bot_member.top_role = 50 + bot_member.top_role = 500 guild.me = bot_member guild.get_role.return_value = role guild.get_member.return_value = member config = cog._ensure_setup(guild.id) config.member_role_id = role + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.attempts = 1 + + grace_task = MagicMock() + grace_task.done.return_value = False + cog._grace_tasks[cog._state_key(guild.id, member.id)] = grace_task interaction = MagicMock(spec=discord.Interaction) interaction.guild = guild interaction.response = MagicMock() interaction.response.send_message = AsyncMock() - await cog._handle_accept(interaction, member.id) + completed = await cog._handle_accept(interaction, member.id, 1) + assert completed is True member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance") - state = cog._get_user_state(guild.id, member.id) assert state.status == "verified" + grace_task.cancel.assert_called_once() + assert cog._state_key(guild.id, member.id) not in cog._grace_tasks interaction.response.send_message.assert_called_once() @@ -242,7 +277,7 @@ async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch): bot_member = MagicMock() bot_member.guild_permissions = SimpleNamespace(kick_members=True) - bot_member.top_role = 50 + bot_member.top_role = 500 guild.me = bot_member guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None cog.bot.get_guild.return_value = guild @@ -255,12 +290,216 @@ async def test_enforce_grace_period_kicks_unverified_member(cog, monkeypatch): state = cog._get_user_state(guild.id, member.id) state.status = "pending" state.started_at_utc = utc_now() + state.attempts = 1 async def fake_sleep(_seconds: float) -> None: state.started_at_utc = utc_now() - timedelta(hours=2) monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) - await cog._enforce_grace_period(guild.id, member.id) + await cog._enforce_grace_period(guild.id, member.id, 1) member.kick.assert_called_once_with(reason="Did not complete onboarding within the configured grace period") + + +@pytest.mark.asyncio +async def test_timeout_resets_state_before_retry_and_cancels_previous_grace_task(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 808 + + member = MagicMock(spec=discord.Member) + member.id = 909 + member.mention = "<@909>" + member.guild = guild + + cog.bot.get_guild.return_value = guild + guild.get_member.return_value = member + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.completed_at_utc = utc_now() + state.attempts = 1 + + config = cog._ensure_setup(guild.id) + config.log_events = False + + grace_task = MagicMock() + grace_task.done.return_value = False + cog._grace_tasks[cog._state_key(guild.id, member.id)] = grace_task + + captured_state = {} + + async def fake_send_verification_prompt(target_member, *, is_retry: bool = False) -> bool: + current = cog._get_user_state(target_member.guild.id, target_member.id) + captured_state["status"] = current.status + captured_state["started_at_utc"] = current.started_at_utc + captured_state["completed_at_utc"] = current.completed_at_utc + captured_state["attempts"] = current.attempts + captured_state["is_retry"] = is_retry + return True + + cog._send_verification_prompt = fake_send_verification_prompt + + await cog._handle_verification_timeout(guild.id, 1, member.id) + + grace_task.cancel.assert_called_once() + assert captured_state == { + "status": "new", + "started_at_utc": None, + "completed_at_utc": None, + "attempts": 1, + "is_retry": True, + } + + +@pytest.mark.asyncio +async def test_stale_grace_period_task_does_not_kick_after_timeout_retry(cog, monkeypatch): + guild = MagicMock(spec=discord.Guild) + guild.id = 818 + + member = MagicMock(spec=discord.Member) + member.id = 919 + member.mention = "<@919>" + member.top_role = 1 + member.guild = guild + member.kick = AsyncMock() + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(kick_members=True) + bot_member.top_role = 50 + guild.me = bot_member + guild.get_member.side_effect = lambda user_id: member if user_id == member.id else None + cog.bot.get_guild.return_value = guild + + config = cog._ensure_setup(guild.id) + config.auto_kick_unverified = True + config.grace_period_hours = 1 + config.log_events = False + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.attempts = 2 + + async def fake_sleep(_seconds: float) -> None: + state.started_at_utc = utc_now() - timedelta(hours=2) + + monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) + + await cog._enforce_grace_period(guild.id, member.id, 1) + + member.kick.assert_not_called() + + +@pytest.mark.asyncio +async def test_retry_view_allows_same_user_to_complete_onboarding(cog): + guild = MagicMock(spec=discord.Guild) + guild.id = 828 + + role = 222 + + welcome_messages = [] + + def make_message(*_args, **_kwargs) -> MagicMock: + message = MagicMock(spec=discord.Message) + message.edit = AsyncMock() + welcome_messages.append(message) + return message + + welcome_channel = MagicMock(spec=discord.TextChannel) + welcome_channel.id = 444 + welcome_channel.send = AsyncMock(side_effect=make_message) + + member = MagicMock(spec=discord.Member) + member.id = 929 + member.mention = "<@929>" + member.guild = guild + member.roles = [] + member.add_roles = AsyncMock() + member.send = AsyncMock() + member.top_role = 1 + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(manage_roles=True) + bot_member.top_role = 500 + guild.me = bot_member + guild.get_channel.return_value = welcome_channel + guild.get_role.return_value = role + guild.get_member.return_value = member + cog.bot.get_guild.return_value = guild + cog._schedule_grace_period_check = MagicMock() + + config = cog._ensure_setup(guild.id) + config.welcome_channel_id = welcome_channel.id + config.member_role_id = role + config.rules_location = "#rules" + config.log_events = False + + await cog.on_member_join(member) + + first_view = welcome_channel.send.call_args_list[0].kwargs["view"] + await first_view.on_timeout() + + assert welcome_channel.send.call_count == 2 + second_view = welcome_channel.send.call_args_list[1].kwargs["view"] + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.user = SimpleNamespace(id=member.id) + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + await second_view.children[0].callback(interaction) + + member.add_roles.assert_called_once_with(role, reason="Completed onboarding rule acceptance") + state = cog._get_user_state(guild.id, member.id) + assert state.status == "verified" + + +@pytest.mark.asyncio +async def test_verified_user_is_not_removed_by_old_grace_task(cog, monkeypatch): + guild = MagicMock(spec=discord.Guild) + guild.id = 838 + + member = MagicMock(spec=discord.Member) + member.id = 939 + member.mention = "<@939>" + member.top_role = 1 + member.guild = guild + member.kick = AsyncMock() + + bot_member = MagicMock() + bot_member.guild_permissions = SimpleNamespace(kick_members=True, manage_roles=True) + bot_member.top_role = 500 + guild.me = bot_member + guild.get_member.return_value = member + guild.get_role.return_value = 123 + cog.bot.get_guild.return_value = guild + + config = cog._ensure_setup(guild.id) + config.member_role_id = 123 + config.auto_kick_unverified = True + config.grace_period_hours = 1 + config.log_events = False + + state = cog._get_user_state(guild.id, member.id) + state.status = "pending" + state.started_at_utc = utc_now() + state.attempts = 1 + + async def fake_sleep(_seconds: float) -> None: + state.started_at_utc = utc_now() - timedelta(hours=2) + + monkeypatch.setattr("capy_discord.exts.setup.setup.asyncio.sleep", fake_sleep) + + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = guild + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + + completed = await cog._handle_accept(interaction, member.id, 1) + + assert completed is True + await cog._enforce_grace_period(guild.id, member.id, 1) + member.kick.assert_not_called()