diff --git a/AGENTS.md b/AGENTS.md index 67d762f3..b26c7f14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,11 @@ Repository guidance for coding agents working in `DiscordModBot`. SPRING_DATASOURCE_DRIVERCLASSNAME=org.mariadb.jdbc.Driver \ ./gradlew check ``` + - On some machines Docker is Podman-backed: the `docker compose` v2 plugin is unavailable; use the standalone + `docker-compose` binary instead (e.g. `docker-compose up -d`). + - Environment variables do not invalidate Gradle's up-to-date checks, so a `check` run right after a plain + `./gradlew check` will skip `test` as UP-TO-DATE. Force re-execution with `./gradlew check --rerun-tasks` to + actually run the tests against MariaDB. - Prefer H2 unless changing DB-specific behavior. - Some tests (e.g. `GuildWarnPointRepositoryTest`) are `@Disabled` due to known Hibernate issues; leave them unless fixing the root cause. diff --git a/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommand.kt b/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommand.kt index 46faa229..9d415836 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommand.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommand.kt @@ -19,7 +19,9 @@ import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.interactions.InteractionContextType import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions +import net.dv8tion.jda.api.interactions.commands.OptionType import net.dv8tion.jda.api.interactions.commands.build.Commands +import net.dv8tion.jda.api.interactions.commands.build.OptionData import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData import net.dv8tion.jda.api.utils.MarkdownUtil import net.dv8tion.jda.api.utils.TimeFormat @@ -37,6 +39,7 @@ class ReviewCommand( ) : ListenerAdapter(), SlashCommand { companion object { private const val COMMAND = "review" + private const val OPTION_MAX_MEMBERS = "max-members" private const val BUTTON_PREFIX = "member-gate-review:" private const val INTERRUPT_CONFIRM_PREFIX = "member-gate-review-interrupt:" private const val APPROVE_ACTION = "approve" @@ -62,6 +65,10 @@ class ReviewCommand( Commands.slash(COMMAND, "Review pending member gate applications") .setContexts(InteractionContextType.GUILD) .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_ROLES)) + .addOptions( + OptionData(OptionType.INTEGER, OPTION_MAX_MEMBERS, "Maximum number of members to review") + .setMinValue(1) + ) ) } @@ -81,7 +88,9 @@ class ReviewCommand( return } - val session = reviewManager.createSession(guild.idLong) + reviewManager.pruneStaleApplicants(guild, event.jda) + val maxMembers = event.getOption(OPTION_MAX_MEMBERS)?.asInt + val session = reviewManager.createSession(guild.idLong, maxMembers) if (session == null) { event.reply("Nobody is currently waiting for approval.").setEphemeral(true).queue() return @@ -96,7 +105,7 @@ class ReviewCommand( val otherSessions = reviewSessionRegistry.getOtherSessions(guild.idLong, event.user.idLong) if (otherSessions.isNotEmpty()) { - val token = rememberInterruptConfirmation(guild.idLong, event.user.idLong, otherSessions) + val token = rememberInterruptConfirmation(guild.idLong, event.user.idLong, otherSessions, maxMembers) event.reply(buildInterruptPrompt(guild, otherSessions)) .setEphemeral(true) .addComponents(ActionRow.of(buildInterruptButtons(token))) @@ -230,7 +239,9 @@ class ReviewCommand( if (pendingQuestion == null) { logReviewCompleted(guild, event.member!!, session) reviewSessionRegistry.forget(guild.idLong, event.user.idLong) - event.editMessage(buildCompletionMessage(feedback)).setComponents(emptyList()).queue() + event.editMessage(buildCompletionMessage(feedback, reviewManager.hasPendingApplicants(guild.idLong))) + .setComponents(emptyList()) + .queue() return } @@ -247,7 +258,9 @@ class ReviewCommand( if (pendingQuestion == null) { logReviewCompleted(guild, event.member!!, session) reviewSessionRegistry.forget(guild.idLong, event.user.idLong) - event.editMessage(buildCompletionMessage(feedback)).setComponents(emptyList()).queue() + event.editMessage(buildCompletionMessage(feedback, reviewManager.hasPendingApplicants(guild.idLong))) + .setComponents(emptyList()) + .queue() return } @@ -315,7 +328,8 @@ class ReviewCommand( return } - val session = reviewManager.createSession(guild.idLong) + reviewManager.pruneStaleApplicants(guild, event.jda) + val session = reviewManager.createSession(guild.idLong, confirmation.maxMembers) if (session == null) { event.editMessage("Nobody is currently waiting for approval.").setComponents(emptyList()).queue() return @@ -355,7 +369,8 @@ class ReviewCommand( private fun rememberInterruptConfirmation( guildId: Long, reviewerId: Long, - sessions: List + sessions: List, + maxMembers: Int? = null ): String { val token = UUID.randomUUID().toString().replace("-", "").take(12) reviewInterruptConfirmationRepository.save( @@ -363,7 +378,8 @@ class ReviewCommand( id = token, guildId = guildId, reviewerId = reviewerId, - targetSessionIds = sessions.associate { it.reviewerId to it.sessionId } + targetSessionIds = sessions.associate { it.reviewerId to it.sessionId }, + maxMembers = maxMembers ) ) return token @@ -407,8 +423,12 @@ class ReviewCommand( "\nChoose `Approve`, `Reject`, or `Manual action`." } - private fun buildCompletionMessage(feedback: String): String { - return "$feedback\n\nThere are no more pending applicants in the queue." + private fun buildCompletionMessage(feedback: String, hasMoreApplicants: Boolean): String { + return if (hasMoreApplicants) { + "$feedback\n\nThere are still applicants waiting for approval. Run `/review` again to continue." + } else { + "$feedback\n\nThere are no more pending applicants in the queue." + } } private fun buildInterruptPrompt( @@ -436,11 +456,17 @@ class ReviewCommand( } private fun logReviewStarted(guild: Guild, moderator: Member, session: ReviewSession) { + val sessionSize = session.toPendingUserIds().size + val totalPending = reviewManager.countPendingApplicants(guild.idLong) val logEmbed = EmbedBuilder() .setColor(Color.GREEN) .setTitle("Member gate review started") .addField("Moderator", moderator.nicknameAndUsername, false) - .addField("Pending applicants", session.toPendingUserIds().size.toString(), true) + .addField("Pending applicants", totalPending.toString(), true) + + if (sessionSize != totalPending) { + logEmbed.addField("In this session", sessionSize.toString(), true) + } guildLogger.log(logEmbed, moderator.user, guild, null, GuildLogger.LogTypeAction.MODERATOR) } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManager.kt b/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManager.kt index 22dc433c..82f651d7 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManager.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManager.kt @@ -5,6 +5,9 @@ import be.duncanc.discordmodbot.member.gate.persistence.MemberGateQuestionReposi import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.entities.Guild import net.dv8tion.jda.api.entities.Member +import net.dv8tion.jda.api.exceptions.ErrorResponseException +import net.dv8tion.jda.api.requests.ErrorResponse +import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.util.concurrent.TimeUnit @@ -15,8 +18,12 @@ class ReviewManager( private val memberGateService: MemberGateService, private val promptRegistry: ReviewPromptRegistry ) { + companion object { + private val LOG = LoggerFactory.getLogger(ReviewManager::class.java) + } + @Transactional(readOnly = true) - fun createSession(guildId: Long): ReviewSession? { + fun createSession(guildId: Long, maxMembers: Int? = null): ReviewSession? { val storedQuestions = memberGateQuestionRepository.findAll() val pendingUserIds = storedQuestions @@ -25,11 +32,50 @@ class ReviewManager( .filter { it.guildId == guildId && it.userId.toULong() > 0uL } .sortedBy { it.queuedAt } .map { it.userId } + .take(maxMembers ?: Int.MAX_VALUE) .toList() return pendingUserIds.takeIf { it.isNotEmpty() }?.let(::ReviewSession) } + @Transactional + fun pruneStaleApplicants(guild: Guild, jda: JDA) { + memberGateQuestionRepository.findAll() + .filterNotNull() + .filter { it.guildId == guild.idLong && it.userId.toULong() > 0uL } + .forEach { question -> + guild.retrieveMemberById(question.userId).queue( + { }, + { throwable -> + if ((throwable as? ErrorResponseException)?.errorResponse == ErrorResponse.UNKNOWN_MEMBER) { + clearPendingQuestion(guild.idLong, jda, question.userId) + } else { + LOG.warn( + "Failed to check membership of {} in guild {}; keeping the pending question.", + question.userId, + guild.idLong, + throwable + ) + } + } + ) + } + } + + @Transactional(readOnly = true) + fun hasPendingApplicants(guildId: Long): Boolean { + return memberGateQuestionRepository.findAll() + .filterNotNull() + .any { it.guildId == guildId && it.userId.toULong() > 0uL } + } + + @Transactional(readOnly = true) + fun countPendingApplicants(guildId: Long): Int { + return memberGateQuestionRepository.findAll() + .filterNotNull() + .count { it.guildId == guildId && it.userId.toULong() > 0uL } + } + @Transactional(readOnly = true) fun getPendingQuestion(guildId: Long, userId: Long): MemberGateQuestion? { return memberGateQuestionRepository.findById(MemberGateQuestion.createId(guildId, userId)).orElse(null) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/member/gate/persistence/ReviewInterruptConfirmation.kt b/src/main/kotlin/be/duncanc/discordmodbot/member/gate/persistence/ReviewInterruptConfirmation.kt index c2082098..ffd0a37a 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/member/gate/persistence/ReviewInterruptConfirmation.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/member/gate/persistence/ReviewInterruptConfirmation.kt @@ -9,5 +9,6 @@ data class ReviewInterruptConfirmation( val id: String, val guildId: Long, val reviewerId: Long, - val targetSessionIds: Map + val targetSessionIds: Map, + val maxMembers: Int? = null ) diff --git a/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommandTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommandTest.kt index 140cca0d..efe4c1a8 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommandTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewCommandTest.kt @@ -15,6 +15,7 @@ import net.dv8tion.jda.api.entities.MessageEmbed import net.dv8tion.jda.api.entities.User import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent +import net.dv8tion.jda.api.interactions.commands.OptionMapping import net.dv8tion.jda.api.requests.restaction.interactions.MessageEditCallbackAction import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction import org.junit.jupiter.api.Assertions.assertEquals @@ -252,6 +253,7 @@ class ReviewCommandTest { val session = ReviewSession(listOf(10L, 20L)) whenever(reviewManager.createSession(1L)).thenReturn(session) + whenever(reviewManager.countPendingApplicants(1L)).thenReturn(2) whenever(reviewManager.getPendingQuestion(1L, 10L)).thenReturn( pendingQuestion(guildId = 1L, userId = 10L, question = "Q1", answer = "A1", queuedAt = 10L) ) @@ -262,6 +264,123 @@ class ReviewCommandTest { verifyReviewLog("Member gate review started", "Pending applicants", "2") } + @Test + fun `starting review with max members passes the limit to session creation`() { + stubSlashReviewStart() + stubModeratorName() + + val maxMembersOption = mock() + whenever(maxMembersOption.asInt).thenReturn(2) + whenever(slashEvent.getOption("max-members")).thenReturn(maxMembersOption) + + val session = ReviewSession(listOf(10L, 20L)) + whenever(reviewManager.createSession(1L, 2)).thenReturn(session) + whenever(reviewManager.getPendingQuestion(1L, 10L)).thenReturn( + pendingQuestion(guildId = 1L, userId = 10L, question = "Q1", answer = "A1", queuedAt = 10L) + ) + stubApplicantPresent(10L, "<@10>") + + command.onSlashCommandInteraction(slashEvent) + + verify(reviewManager).createSession(1L, 2) + verify(reviewManager).pruneStaleApplicants(guild, jda) + verify(reviewSessionRegistry).remember(eq(1L), eq(99L), same(session)) + val messageCaptor = argumentCaptor() + verify(slashEvent).reply(messageCaptor.capture()) + assertTrue(messageCaptor.firstValue.contains("Applicant: <@10> (`10`)")) + } + + @Test + fun `starting review with max members logs queue total and session size`() { + stubSlashReviewStart() + stubModeratorName() + + val maxMembersOption = mock() + whenever(maxMembersOption.asInt).thenReturn(2) + whenever(slashEvent.getOption("max-members")).thenReturn(maxMembersOption) + + val session = ReviewSession(listOf(10L, 20L)) + whenever(reviewManager.createSession(1L, 2)).thenReturn(session) + whenever(reviewManager.countPendingApplicants(1L)).thenReturn(3) + whenever(reviewManager.getPendingQuestion(1L, 10L)).thenReturn( + pendingQuestion(guildId = 1L, userId = 10L, question = "Q1", answer = "A1", queuedAt = 10L) + ) + stubApplicantPresent(10L, "<@10>") + + command.onSlashCommandInteraction(slashEvent) + + verifyReviewLog("Member gate review started", "Pending applicants", "3") + verifyReviewLog("Member gate review started", "In this session", "2") + } + + @Test + fun `confirming interruption carries the max members limit over to the new session`() { + stubInterruptButtonInteraction("member-gate-review-interrupt:confirm:token") + stubModeratorName() + stubButtonEditWithActionRow() + + val storedSession = ReviewSessionRegistry.StoredReviewSession( + reviewerId = 42L, + session = ReviewSession(listOf(50L), sessionId = "session-42"), + sessionId = "session-42", + updatedAt = Instant.parse("2026-06-28T12:00:00Z") + ) + whenever(reviewInterruptConfirmationRepository.findById("token")).thenReturn( + Optional.of( + ReviewInterruptConfirmation( + id = "token", + guildId = 1L, + reviewerId = 99L, + targetSessionIds = mapOf(42L to "session-42"), + maxMembers = 5 + ) + ) + ) + whenever(reviewSessionRegistry.getOtherSessions(1L, 99L)).thenReturn(listOf(storedSession)) + whenever(reviewSessionRegistry.forgetSessions(1L, setOf(42L))).thenReturn(listOf(storedSession)) + + val session = ReviewSession(listOf(10L)) + whenever(reviewManager.createSession(1L, 5)).thenReturn(session) + whenever(reviewManager.getPendingQuestion(1L, 10L)).thenReturn( + pendingQuestion(guildId = 1L, userId = 10L, question = "Q1", answer = "A1", queuedAt = 10L) + ) + whenever(guild.getMemberById(42L)).thenReturn(null) + stubApplicantPresent(10L, "<@10>") + + command.onButtonInteraction(buttonEvent) + + verify(reviewManager).createSession(1L, 5) + verify(reviewSessionRegistry).remember(eq(1L), eq(99L), same(session)) + } + + @Test + fun `approve completion mentions remaining applicants when more are waiting`() { + stubSlashReviewStart() + stubApproveButtonInteraction() + stubButtonEditWithList() + + val session = ReviewSession(listOf(10L)) + whenever(reviewManager.createSession(1L)).thenReturn(session) + whenever(reviewSessionRegistry.get(1L, 99L)).thenReturn(null, session) + stubModeratorName() + whenever(reviewManager.getPendingQuestion(1L, 10L)).thenReturn( + pendingQuestion(guildId = 1L, userId = 10L, question = "Q1", answer = "A1", queuedAt = 10L) + ) + stubApplicantPresent(10L, "<@10>") + whenever(reviewManager.approve(eq(guild), eq(jda), eq(10L))).thenReturn("Approved <@10>.") + whenever(reviewManager.hasPendingApplicants(1L)).thenReturn(true) + + command.onSlashCommandInteraction(slashEvent) + command.onButtonInteraction(buttonEvent) + + val messageCaptor = argumentCaptor() + verify(buttonEvent).editMessage(messageCaptor.capture()) + val completionMessage = messageCaptor.allValues.last() + assertTrue(completionMessage.contains("Approved <@10>.")) + val expectedMessage = "There are still applicants waiting for approval. Run `/review` again to continue." + assertTrue(completionMessage.contains(expectedMessage)) + } + @Test fun `starting review again continues existing moderator session`() { stubSlashReviewCommand() @@ -283,7 +402,7 @@ class ReviewCommandTest { verify(slashEvent).reply(messageCaptor.capture()) assertTrue(messageCaptor.firstValue.contains("Applicant: <@20> (`20`)")) assertTrue(messageCaptor.firstValue.contains("Continuing with the next pending applicant.")) - verify(reviewManager, never()).createSession(any()) + verify(reviewManager, never()).createSession(any(), anyOrNull()) verify(reviewSessionRegistry, never()).forgetOtherSessions(any(), any()) verify(reviewSessionRegistry).remember(eq(1L), eq(99L), same(storedSession)) verify(guildLogger, never()).log( @@ -455,7 +574,7 @@ class ReviewCommandTest { verify(buttonEvent).editMessage("The active review sessions changed. Run `/review` again to confirm the current sessions.") verify(reviewInterruptConfirmationRepository).deleteById("token") verify(reviewSessionRegistry, never()).forgetSessions(any(), any()) - verify(reviewManager, never()).createSession(any()) + verify(reviewManager, never()).createSession(any(), anyOrNull()) } @Test @@ -495,7 +614,7 @@ class ReviewCommandTest { verify(buttonEvent).editMessage("The active review sessions changed. Run `/review` again to confirm the current sessions.") verify(reviewInterruptConfirmationRepository).deleteById("token") verify(reviewSessionRegistry, never()).forgetSessions(any(), any()) - verify(reviewManager, never()).createSession(any()) + verify(reviewManager, never()).createSession(any(), anyOrNull()) } @Test @@ -571,7 +690,7 @@ class ReviewCommandTest { verify(buttonEvent).editMessage("Review start cancelled. The other moderator's review session was not interrupted.") verify(reviewInterruptConfirmationRepository).deleteById("token") verify(reviewSessionRegistry, never()).forgetOtherSessions(any(), any()) - verify(reviewManager, never()).createSession(any()) + verify(reviewManager, never()).createSession(any(), anyOrNull()) } @Test diff --git a/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManagerTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManagerTest.kt index cc9df936..b08589c9 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManagerTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/member/gate/ReviewManagerTest.kt @@ -8,7 +8,11 @@ import net.dv8tion.jda.api.entities.Member import net.dv8tion.jda.api.entities.Role import net.dv8tion.jda.api.entities.User import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.exceptions.ErrorResponseException +import net.dv8tion.jda.api.requests.ErrorResponse +import net.dv8tion.jda.api.requests.Response import net.dv8tion.jda.api.requests.restaction.AuditableRestAction +import net.dv8tion.jda.api.requests.restaction.CacheRestAction import net.dv8tion.jda.api.requests.restaction.MessageCreateAction import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -19,6 +23,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.* import java.util.* +import java.util.function.Consumer @ExtendWith(MockitoExtension::class) class ReviewManagerTest { @@ -102,6 +107,111 @@ class ReviewManagerTest { assertEquals(20L, session?.advanceAfterReview()) } + @Test + fun `createSession limits the session to the oldest max members`() { + val repositoryEntries = listOf( + pendingQuestion(guildId = 1L, userId = 30L, queuedAt = 30L, question = "Q3", answer = "A3"), + pendingQuestion(guildId = 1L, userId = 10L, queuedAt = 10L, question = "Q1", answer = "A1"), + pendingQuestion(guildId = 1L, userId = 20L, queuedAt = 20L, question = "Q2", answer = "A2") + ) + whenever(memberGateQuestionRepository.findAll()).thenReturn(repositoryEntries) + + val session = reviewManager.createSession(1L, 2) + + assertEquals(10L, session?.getCurrentUserId()) + assertEquals(20L, session?.advanceAfterReview()) + assertNull(session?.advanceAfterReview()) + } + + @Test + fun `hasPendingApplicants returns true when the guild has queued applicants`() { + val repositoryEntries = listOf( + pendingQuestion(guildId = 1L, userId = 10L, queuedAt = 10L, question = "Q1", answer = "A1") + ) + whenever(memberGateQuestionRepository.findAll()).thenReturn(repositoryEntries) + + assertEquals(true, reviewManager.hasPendingApplicants(1L)) + } + + @Test + fun `hasPendingApplicants returns false when the guild has no queued applicants`() { + val repositoryEntries = listOf( + pendingQuestion(guildId = 2L, userId = 10L, queuedAt = 10L, question = "Q1", answer = "A1") + ) + whenever(memberGateQuestionRepository.findAll()).thenReturn(repositoryEntries) + + assertEquals(false, reviewManager.hasPendingApplicants(1L)) + } + + @Test + fun `countPendingApplicants counts only the guild's queued applicants`() { + val repositoryEntries = listOf( + pendingQuestion(guildId = 1L, userId = 10L, queuedAt = 10L, question = "Q1", answer = "A1"), + pendingQuestion(guildId = 1L, userId = 20L, queuedAt = 20L, question = "Q2", answer = "A2"), + pendingQuestion(guildId = 2L, userId = 99L, queuedAt = 5L, question = "QX", answer = "AX") + ) + whenever(memberGateQuestionRepository.findAll()).thenReturn(repositoryEntries) + + assertEquals(2, reviewManager.countPendingApplicants(1L)) + } + + @Test + fun `pruneStaleApplicants removes only applicants who left the guild`() { + val staleQuestion = pendingQuestion(guildId = 1L, userId = 20L, queuedAt = 20L, question = "Q2", answer = "A2") + val repositoryEntries = listOf( + pendingQuestion(guildId = 1L, userId = 10L, queuedAt = 10L, question = "Q1", answer = "A1"), + staleQuestion, + pendingQuestion(guildId = 2L, userId = 99L, queuedAt = 5L, question = "QX", answer = "AX") + ) + whenever(memberGateQuestionRepository.findAll()).thenReturn(repositoryEntries) + whenever(memberGateQuestionRepository.findById(MemberGateQuestion.createId(1L, 20L))).thenReturn( + Optional.of(staleQuestion) + ) + whenever(guild.idLong).thenReturn(1L) + val presentMemberAction = mock>() + whenever(guild.retrieveMemberById(10L)).thenReturn(presentMemberAction) + doAnswer { invocation -> + invocation.component1>().accept(member) + null + }.whenever(presentMemberAction).queue(any(), any()) + val missingMemberAction = mock>() + whenever(guild.retrieveMemberById(20L)).thenReturn(missingMemberAction) + doAnswer { invocation -> + invocation.component2>().accept( + ErrorResponseException.create(ErrorResponse.UNKNOWN_MEMBER, Response(10007L, emptySet())) + ) + null + }.whenever(missingMemberAction).queue(any(), any()) + whenever(promptRegistry.forget(1L, 20L)).thenReturn(null) + + reviewManager.pruneStaleApplicants(guild, jda) + + verify(memberGateQuestionRepository).deleteById(MemberGateQuestion.createId(1L, 20L)) + verify(memberGateQuestionRepository, never()).deleteById(MemberGateQuestion.createId(1L, 10L)) + verify(memberGateQuestionRepository, never()).deleteById(MemberGateQuestion.createId(2L, 99L)) + } + + @Test + fun `pruneStaleApplicants keeps applicants when member retrieval fails transiently`() { + val repositoryEntries = listOf( + pendingQuestion(guildId = 1L, userId = 20L, queuedAt = 20L, question = "Q2", answer = "A2") + ) + whenever(memberGateQuestionRepository.findAll()).thenReturn(repositoryEntries) + whenever(guild.idLong).thenReturn(1L) + val failingMemberAction = mock>() + whenever(guild.retrieveMemberById(20L)).thenReturn(failingMemberAction) + doAnswer { invocation -> + invocation.component2>().accept( + ErrorResponseException.create(ErrorResponse.SERVER_ERROR, Response(500L, emptySet())) + ) + null + }.whenever(failingMemberAction).queue(any(), any()) + + reviewManager.pruneStaleApplicants(guild, jda) + + verify(memberGateQuestionRepository, never()).deleteById(MemberGateQuestion.createId(1L, 20L)) + } + @Test fun `getPendingQuestion uses guild scoped redis id`() { val question = pendingQuestion(guildId = 1L, userId = 10L, queuedAt = 10L, question = "Q1", answer = "A1")