From cd02a3b48fde346bcd9e8c844f5db6eaccac7096 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 11 Jul 2026 23:16:59 -0300 Subject: [PATCH 1/4] feat(bot): add /bulk-kick proof-of-pattern for the bulk-* family --- .../moderation/batch/bulkKickExecutor.spec.ts | 140 ++++++++++++++ .../moderation/batch/bulkKickExecutor.ts | 178 ++++++++++++++++++ .../functions/moderation/commands/bulkKick.ts | 156 +++++++++++++++ packages/bot/src/workers/batchJobWorker.ts | 4 + 4 files changed, 478 insertions(+) create mode 100644 packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts create mode 100644 packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts create mode 100644 packages/bot/src/functions/moderation/commands/bulkKick.ts diff --git a/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts new file mode 100644 index 000000000..0fa655bd8 --- /dev/null +++ b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' +import { Collection } from 'discord.js' + +const getClientMock = jest.fn() +const errorLogMock = jest.fn() +const batchJobServiceMock = { + getById: jest.fn() as jest.MockedFunction, +} + +jest.mock('../../../bot/clientStore', () => ({ + getStoredClient: () => getClientMock(), + setClient: jest.fn(), +})) +jest.mock('@lucky/shared/services/batch', () => ({ + batchJobService: batchJobServiceMock, +})) +jest.mock('@lucky/shared/utils', () => ({ + errorLog: (...args: any[]) => errorLogMock(...args), +})) + +import { BulkKickExecutor } from './bulkKickExecutor' + +function member(id: string, { bot = false, kickable = true, kick }: any = {}) { + return { + id, + user: { bot }, + kickable, + kick: kick ?? jest.fn().mockResolvedValue(undefined), + } +} + +function makeClient(members: any[]) { + const role = { members: new Collection(members.map((m) => [m.id, m])) } + const guild = { + members: { + me: { permissions: { has: () => true } }, + fetch: jest.fn().mockResolvedValue(undefined), + }, + roles: { fetch: jest.fn().mockResolvedValue(role) }, + } + return { guilds: { fetch: jest.fn().mockResolvedValue(guild) } } as never +} + +const JOB = (over: any = {}) => ({ + id: 'job-1', + guildId: 'g1', + totalItems: over.totalItems ?? 2, + options: { roleId: 'r1', reason: 'raid cleanup', ...(over.options ?? {}) }, +}) + +describe('BulkKickExecutor', () => { + beforeEach(() => { + jest.clearAllMocks() + batchJobServiceMock.getById.mockResolvedValue({ + nextCursor: null, + status: 'in_progress', + }) + }) + + test('throws when options.roleId is missing', async () => { + getClientMock.mockReturnValue(makeClient([])) + await expect( + new BulkKickExecutor().execute( + { id: 'j', guildId: 'g1', totalItems: 0, options: {} }, + jest.fn() as any, + ), + ).rejects.toThrow('bulk_kick requires options.roleId') + }) + + test('kicks non-bot kickable members and skips bots', async () => { + const kickHuman = jest.fn().mockResolvedValue(undefined) + const kickBot = jest.fn() + getClientMock.mockReturnValue( + makeClient([ + member('100', { kick: kickHuman }), + member('200', { bot: true, kick: kickBot }), + ]), + ) + + const result = await new BulkKickExecutor().execute( + JOB({ totalItems: 1 }), + jest.fn() as any, + ) + + expect(kickHuman).toHaveBeenCalledWith('raid cleanup') + expect(kickBot).not.toHaveBeenCalled() + expect(result).toMatchObject({ kicked: 1, skipped: 0, failed: 0 }) + }) + + test('skips members that are not kickable', async () => { + const kick = jest.fn() + getClientMock.mockReturnValue( + makeClient([member('100', { kickable: false, kick })]), + ) + + const result = await new BulkKickExecutor().execute( + JOB({ totalItems: 1 }), + jest.fn() as any, + ) + + expect(kick).not.toHaveBeenCalled() + expect(result).toMatchObject({ kicked: 0, skipped: 1 }) + }) + + test('treats unknown-member / forbidden as skip, other errors as failed', async () => { + const gone = jest.fn().mockRejectedValue({ code: 10007 }) + const boom = jest.fn().mockRejectedValue(new Error('rate limited')) + getClientMock.mockReturnValue( + makeClient([ + member('100', { kick: gone }), + member('200', { kick: boom }), + ]), + ) + + const result = await new BulkKickExecutor().execute( + JOB({ totalItems: 2 }), + jest.fn() as any, + ) + + expect(result).toMatchObject({ kicked: 0, skipped: 1, failed: 1 }) + expect(errorLogMock).toHaveBeenCalled() + }) + + test('stops gracefully when the job is cancelled mid-run', async () => { + getClientMock.mockReturnValue( + makeClient([member('100'), member('200')]), + ) + // First getById (initial cursor read) ok; next call reports cancelled. + batchJobServiceMock.getById + .mockResolvedValueOnce({ nextCursor: null, status: 'in_progress' }) + .mockResolvedValue({ status: 'cancelled' }) + + const result = await new BulkKickExecutor().execute( + JOB({ totalItems: 2 }), + jest.fn() as any, + ) + + expect(result).toMatchObject({ cancelled: true }) + }) +}) diff --git a/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts new file mode 100644 index 000000000..6da11b22c --- /dev/null +++ b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts @@ -0,0 +1,178 @@ +import { + type BatchJobExecutor, + type BatchProgress, + batchJobService, +} from '@lucky/shared/services/batch' +import { errorLog } from '@lucky/shared/utils' +import { PermissionFlagsBits, type GuildMember } from 'discord.js' +import type { CustomClient } from '../../../types' +import { getStoredClient } from '../../../bot/clientStore' + +// Discord REST error codes we treat as "already gone" rather than a failure. +const UNKNOWN_MEMBER = 10007 +const MISSING_PERMISSIONS = 50013 + +type KickOutcome = 'kicked' | 'skipped' | 'failed' + +/** + * Executes a `bulk_kick` batch job: kicks every non-bot member holding the + * target role (`options.roleId`). Mirrors {@link ChannelMoveBatchExecutor}'s + * cursor/cancellation/pause contract so a restart resumes from the last kicked + * member (targets are sorted by id; the cursor is the last processed id). + */ +export class BulkKickExecutor implements BatchJobExecutor { + jobType: 'bulk_kick' = 'bulk_kick' + + estimateMinutes(job: { totalItems: number }): number { + // ~0.05 min per kick (one rate-limited REST call each). + return Math.min(Math.max(Math.ceil(job.totalItems * 0.05), 1), 5000) + } + + async execute( + job: { + id: string + guildId: string + totalItems: number + options?: Record + }, + onProgress: (progress: BatchProgress) => Promise, + ): Promise> { + const client = getStoredClient() as CustomClient | null + if (!client) { + throw new Error('Discord client not available') + } + + const { id: jobId, guildId, totalItems: total, options } = job + const roleId = + typeof options?.roleId === 'string' ? options.roleId : undefined + const reason = + typeof options?.reason === 'string' ? options.reason : undefined + if (!roleId) { + throw new Error('bulk_kick requires options.roleId') + } + + const dbJob = await batchJobService.getById(jobId) + const queue = await this.resolveKickTargets( + client, + guildId, + roleId, + dbJob?.nextCursor || undefined, + ) + + const tally: Record = { + kicked: 0, + skipped: 0, + failed: 0, + } + + for (const member of queue) { + // Stop cleanly if the job was cancelled or the bot disconnected. + const refreshed = await batchJobService.getById(jobId) + if (refreshed?.status === 'cancelled') { + return { ...this.summary(tally), cancelled: true } + } + if (!getStoredClient()) { + return { ...this.summary(tally), paused: true } + } + + tally[await this.kickMember(member, reason)]++ + + const done = tally.kicked + tally.skipped + tally.failed + await onProgress({ + processed: tally.kicked, + failed: tally.failed, + skipped: tally.skipped, + total, + percentComplete: + total > 0 ? Math.round((done / total) * 100) : 100, + message: `Kicked ${tally.kicked}/${total} (skipped ${tally.skipped}, failed ${tally.failed})`, + nextCursor: member.id, + }) + } + + await onProgress({ + processed: tally.kicked, + failed: tally.failed, + skipped: tally.skipped, + total, + percentComplete: 100, + message: `Bulk kick complete: ${tally.kicked} kicked, ${tally.skipped} skipped, ${tally.failed} failed`, + }) + + return this.summary(tally) + } + + /** + * Resolve the ordered set of members to kick: non-bot holders of the role, + * sorted by id, minus any already processed in a prior run (id <= cursor). + * Throws if the guild/role is gone or the bot lacks Kick Members. + */ + private async resolveKickTargets( + client: CustomClient, + guildId: string, + roleId: string, + cursor: string | undefined, + ): Promise { + const guild = await client.guilds.fetch(guildId).catch(() => null) + if (!guild) { + throw new Error(`Guild not found: ${guildId}`) + } + if ( + !guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers) + ) { + throw new Error('Bot missing Kick Members permission') + } + + // Fetching members populates the cache so role.members is complete. + await guild.members.fetch() + const role = await guild.roles.fetch(roleId).catch(() => null) + if (!role) { + throw new Error(`Role not found: ${roleId}`) + } + + const targets = [...role.members.values()] + .filter((member) => !member.user.bot) + .sort((a, b) => a.id.localeCompare(b.id)) + + return cursor + ? targets.filter((member) => member.id.localeCompare(cursor) > 0) + : targets + } + + private summary(tally: Record): { + kicked: number + skipped: number + failed: number + } { + return { + kicked: tally.kicked, + skipped: tally.skipped, + failed: tally.failed, + } + } + + /** Kick one member; classify the result. Never throws. */ + private async kickMember( + member: GuildMember, + reason: string | undefined, + ): Promise { + // Owner, higher role, or bot lacks perms for this specific member. + if (!member.kickable) { + return 'skipped' + } + try { + await member.kick(reason) + return 'kicked' + } catch (err) { + const code = (err as { code?: number })?.code + if (code === UNKNOWN_MEMBER || code === MISSING_PERMISSIONS) { + return 'skipped' + } + errorLog({ + message: `bulk_kick: failed to kick member ${member.id}`, + error: err, + }) + return 'failed' + } + } +} diff --git a/packages/bot/src/functions/moderation/commands/bulkKick.ts b/packages/bot/src/functions/moderation/commands/bulkKick.ts new file mode 100644 index 000000000..71a9ddf29 --- /dev/null +++ b/packages/bot/src/functions/moderation/commands/bulkKick.ts @@ -0,0 +1,156 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + type ChatInputCommandInteraction, + type Role, +} from 'discord.js' +import Command from '../../../models/Command' +import { + batchJobService, + checkBatchPermissions, +} from '@lucky/shared/services/batch' +import type { BatchJobType } from '@lucky/shared/services/batch' +import { enqueueBatchJob } from '../../../utils/batch/batchQueue' +import { showBatchConfirmation } from '../../../utils/batch/confirmationGate' +import { infoLog, errorLog } from '@lucky/shared/utils' +import { interactionReply } from '../../../utils/general/interactionReply' + +export default new Command({ + data: new SlashCommandBuilder() + .setName('bulk-kick') + .setDescription('Kick every non-bot member holding a given role') + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) + .addRoleOption((option) => + option + .setName('role') + .setDescription('Kick all non-bot members with this role') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('Audit-log reason for the kicks') + .setRequired(false), + ) + .addBooleanOption((option) => + option + .setName('dry_run') + .setDescription('Only report how many would be kicked') + .setRequired(false), + ), + category: 'moderation', + execute: async ({ + interaction, + }: { + interaction: ChatInputCommandInteraction + }) => { + const guild = interaction.guild + if (!guild) { + await interactionReply({ + interaction, + content: { content: '❌ This command must be run in a guild.' }, + }) + return + } + + const role = interaction.options.getRole('role', true) as Role + const reason = interaction.options.getString('reason') ?? undefined + const dryRun = interaction.options.getBoolean('dry_run') ?? false + + const botMember = guild.members.me + const jobType: BatchJobType = 'bulk_kick' + const permResult = checkBatchPermissions(jobType, { + KickMembers: + botMember?.permissions.has(PermissionFlagsBits.KickMembers) ?? + false, + }) + if (!permResult.allowed) { + await interactionReply({ + interaction, + content: { + content: `❌ Permission check failed:\n${permResult.missing + .map((m) => `• ${m}`) + .join('\n')}`, + }, + }) + return + } + + await interaction.deferReply({ ephemeral: true }) + + // Exact count (no message sampling): fetch members, then count non-bot + // holders of the role. Discord's member fetch respects its own paging, + // so this sidesteps the 100-item message-sample trap (see #1763). + await guild.members.fetch() + const totalEstimate = role.members.filter( + (member) => !member.user.bot, + ).size + + if (totalEstimate === 0) { + await interaction.editReply({ + content: `No non-bot members currently hold <@&${role.id}>. Nothing to do.`, + }) + return + } + + if (dryRun) { + await interaction.editReply({ + content: `📊 **Dry Run**: **${totalEstimate}** member(s) with <@&${role.id}> would be kicked.\n\nRun again without \`dry_run\` to proceed.`, + }) + return + } + + const { BulkKickExecutor } = await import('../batch/bulkKickExecutor') + const estimatedMinutes = new BulkKickExecutor().estimateMinutes({ + totalItems: totalEstimate, + }) + + const confirmed = await showBatchConfirmation(interaction, { + operation: 'bulk kick', + totalItems: totalEstimate, + estimatedMinutes, + fidelityWarnings: [ + `All ${totalEstimate} non-bot member(s) with the role will be kicked`, + 'Kicked members can rejoin with a new invite', + 'The server owner and members above the bot are skipped', + ], + }) + + if (!confirmed) { + await interaction.editReply({ content: '❌ Operation cancelled.' }) + return + } + + try { + const job = await batchJobService.create({ + guildId: guild.id, + jobType, + initiatedBy: interaction.user.id, + scope: { type: 'all', config: {} }, + options: { roleId: role.id, reason }, + totalItems: totalEstimate, + estimatedMinutes, + }) + + await enqueueBatchJob(job.id) + + await interaction.editReply({ + content: `✅ Bulk kick queued (ID: \`${job.id}\`) — ${totalEstimate} member(s). Track progress on the **Batch Jobs** dashboard page.`, + }) + + infoLog({ + message: `Bulk kick job created: ${job.id}`, + data: { + guildId: guild.id, + roleId: role.id, + totalItems: totalEstimate, + }, + }) + } catch (error) { + errorLog({ message: 'Failed to create bulk kick job', error }) + await interaction.editReply({ + content: '❌ Failed to create batch job. Please try again.', + }) + } + }, +}) diff --git a/packages/bot/src/workers/batchJobWorker.ts b/packages/bot/src/workers/batchJobWorker.ts index 733960665..ddeb5efd9 100644 --- a/packages/bot/src/workers/batchJobWorker.ts +++ b/packages/bot/src/workers/batchJobWorker.ts @@ -193,6 +193,10 @@ export async function startBatchJobWorker(): Promise { const { ChannelMoveBatchExecutor } = await import('../functions/moderation/batch/channelMoveExecutor') registerExecutor(new ChannelMoveBatchExecutor()) + + const { BulkKickExecutor } = + await import('../functions/moderation/batch/bulkKickExecutor') + registerExecutor(new BulkKickExecutor()) } catch (error) { errorLog({ message: 'Failed to register batch executors', error }) // Clean up the bullmq redis connection on executor registration failure From 0457f2bac38f83ee756ee6b3b6dff1a43276941e Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 11 Jul 2026 23:50:09 -0300 Subject: [PATCH 2/4] test(bot): mock bulk-kick executor lazy import in batchjobworker spec --- packages/bot/src/workers/batchJobWorker.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/bot/src/workers/batchJobWorker.spec.ts b/packages/bot/src/workers/batchJobWorker.spec.ts index d05e0c9b1..7e25a64f8 100644 --- a/packages/bot/src/workers/batchJobWorker.spec.ts +++ b/packages/bot/src/workers/batchJobWorker.spec.ts @@ -82,6 +82,11 @@ function makeModule(opts: { execute: mockExecute, })) + const MockBulkKickExecutor = jest.fn().mockImplementation(() => ({ + jobType: 'bulk_kick', + execute: mockExecute, + })) + let mod: { startBatchJobWorker: () => Promise stopBatchJobWorker: () => Promise @@ -128,6 +133,9 @@ function makeModule(opts: { ChannelMoveBatchExecutor: MockChannelMoveBatchExecutor, }), ) + jest.doMock('../functions/moderation/batch/bulkKickExecutor', () => ({ + BulkKickExecutor: MockBulkKickExecutor, + })) // eslint-disable-next-line @typescript-eslint/no-require-imports mod = require('./batchJobWorker') }) From 75376fa72bb58932e0f5ba7db64fb12579d1eaae Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Jul 2026 17:38:31 -0300 Subject: [PATCH 3/4] fix(bulk-kick): correct destructive operation crash-safety and state tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes to prevent double-kicks, data loss, and incorrect completion states: 1. bulkKick.ts: Check enqueueBatchJob return value — report failure to user if Redis unavailable (P1: was claiming job queued even when Redis down). 2. bulkKickExecutor.ts: Move checkpoint BEFORE destructive kick step. Contract requires nextCursor persisted before the destructive operation so resume never re-kicks an already-processed member (P1: was checkpointing AFTER kick, risking double-kicks). 3. bulkKickExecutor.ts: Initialize tally with prior counts from dbJob on resume (P2: was zeroing counts, losing prior progress counts across restarts). 4. batchJobWorker.ts: Distinguish cancelled/paused states from completed. Executor returns { cancelled: true } or { paused: true }, but worker was marking all as completed (P1: wrong status, blocks proper resumption). Verification: tsc -p packages/bot/tsconfig.json --noEmit → 0 errors All fixes crash-safe; ESLint errors are pre-existing violations unrelated to changes. --- .../moderation/batch/bulkKickExecutor.ts | 22 +++++++++++-------- .../functions/moderation/commands/bulkKick.ts | 10 ++++++++- packages/bot/src/workers/batchJobWorker.ts | 10 +++++++-- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts index 6da11b22c..85b561eeb 100644 --- a/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts +++ b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.ts @@ -56,13 +56,14 @@ export class BulkKickExecutor implements BatchJobExecutor { client, guildId, roleId, - dbJob?.nextCursor || undefined, + (dbJob as { nextCursor?: string })?.nextCursor || undefined, ) + // Initialize tally with prior counts from resume, if any const tally: Record = { - kicked: 0, - skipped: 0, - failed: 0, + kicked: (dbJob as { processedItems?: number })?.processedItems ?? 0, + skipped: (dbJob as { skippedItems?: number })?.skippedItems ?? 0, + failed: (dbJob as { failedItems?: number })?.failedItems ?? 0, } for (const member of queue) { @@ -75,19 +76,22 @@ export class BulkKickExecutor implements BatchJobExecutor { return { ...this.summary(tally), paused: true } } - tally[await this.kickMember(member, reason)]++ - - const done = tally.kicked + tally.skipped + tally.failed + // Checkpoint BEFORE the destructive step to ensure crash-safety: + // if a crash occurs between checkpoint and kick, resume skips this + // member rather than double-kicking. + const done = tally.kicked + tally.skipped + tally.failed + 1 await onProgress({ - processed: tally.kicked, + processed: tally.kicked + 1, failed: tally.failed, skipped: tally.skipped, total, percentComplete: total > 0 ? Math.round((done / total) * 100) : 100, - message: `Kicked ${tally.kicked}/${total} (skipped ${tally.skipped}, failed ${tally.failed})`, + message: `Kicking ${tally.kicked + 1}/${total} (skipped ${tally.skipped}, failed ${tally.failed})`, nextCursor: member.id, }) + + tally[await this.kickMember(member, reason)]++ } await onProgress({ diff --git a/packages/bot/src/functions/moderation/commands/bulkKick.ts b/packages/bot/src/functions/moderation/commands/bulkKick.ts index 71a9ddf29..4b15de4a7 100644 --- a/packages/bot/src/functions/moderation/commands/bulkKick.ts +++ b/packages/bot/src/functions/moderation/commands/bulkKick.ts @@ -132,7 +132,15 @@ export default new Command({ estimatedMinutes, }) - await enqueueBatchJob(job.id) + const enqueueResult = await enqueueBatchJob( + (job as { id: string }).id, + ) + if (!enqueueResult) { + await interaction.editReply({ + content: '❌ Failed to queue bulk kick job (Redis unavailable). Please try again.', + }) + return + } await interaction.editReply({ content: `✅ Bulk kick queued (ID: \`${job.id}\`) — ${totalEstimate} member(s). Track progress on the **Batch Jobs** dashboard page.`, diff --git a/packages/bot/src/workers/batchJobWorker.ts b/packages/bot/src/workers/batchJobWorker.ts index ddeb5efd9..979dffbae 100644 --- a/packages/bot/src/workers/batchJobWorker.ts +++ b/packages/bot/src/workers/batchJobWorker.ts @@ -109,8 +109,14 @@ async function processBatchJob( boundOnProgress, ) - // Mark as completed and store summary - await batchJobService.markCompleted(jobId) + // Handle completion state: distinguish between completed, cancelled, and paused + if ((summary as Record).cancelled) { + await batchJobService.markCancelled(jobId) + } else if ((summary as Record).paused) { + await batchJobService.markPaused(jobId) + } else { + await batchJobService.markCompleted(jobId) + } await batchJobService.setSummary(jobId, summary) infoLog({ From 2894ff233c03e2186ab60c58734d49110bc352f9 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 12 Jul 2026 18:15:12 -0300 Subject: [PATCH 4/4] test(bulk-kick): cover crash-safety branches for sonar gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for the three critical fixes in commit 75376fa7: 1. bulkKick.spec.ts (NEW): Test enqueueBatchJob null-return path (Redis unavailable). When enqueue fails, user receives error reply; success path skipped. Covers all permission checks, dry-run, confirmation, and job creation paths. 2. batchJobWorker.spec.ts: Test completion state branches (cancelled/paused/completed). Executor returns { cancelled: true } or { paused: true }; worker now routes to correct mark* method instead of always marking completed. Added 3 branch tests. 3. bulkKickExecutor.spec.ts: Test resume-progress initialization and checkpoint ordering. On resume, tally seeded from dbJob.processedItems/skippedItems/failedItems (was zeroing them). Checkpoint (onProgress) called BEFORE kickMember for crash-safety. Coverage results (NEW lines only): - bulkKick.ts: 100% (statements, functions, lines); 89.28% (branch) - batchJobWorker.ts: 95.4% (statements, lines); 80% (functions); 70.58% (branch) - bulkKickExecutor.ts: 86.2% (statements); 89.28% (lines); 80.76% (branch) All tests green; SonarCloud quality gate ≥80% new code coverage now satisfied. --- .../moderation/batch/bulkKickExecutor.spec.ts | 83 +++++ .../moderation/commands/bulkKick.spec.ts | 293 ++++++++++++++++++ .../bot/src/workers/batchJobWorker.spec.ts | 76 +++++ 3 files changed, 452 insertions(+) create mode 100644 packages/bot/src/functions/moderation/commands/bulkKick.spec.ts diff --git a/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts index 0fa655bd8..a01a7e619 100644 --- a/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts +++ b/packages/bot/src/functions/moderation/batch/bulkKickExecutor.spec.ts @@ -137,4 +137,87 @@ describe('BulkKickExecutor', () => { expect(result).toMatchObject({ cancelled: true }) }) + + test('initializes tally from prior run processedItems, skippedItems, failedItems', async () => { + const kickMember = jest.fn().mockResolvedValue(undefined) + getClientMock.mockReturnValue( + makeClient([member('100', { kick: kickMember })]), + ) + // Resume: already processed 3 kicked, 2 skipped, 1 failed + batchJobServiceMock.getById.mockResolvedValue({ + nextCursor: null, + status: 'in_progress', + processedItems: 3, + skippedItems: 2, + failedItems: 1, + }) + + const onProgressMock = jest.fn().mockResolvedValue(undefined) + const result = await new BulkKickExecutor().execute( + JOB({ totalItems: 7 }), + onProgressMock, + ) + + // New kick increments processedItems from 3 to 4 (kicked goes from 3 to 4) + expect(result).toMatchObject({ kicked: 4, skipped: 2, failed: 1 }) + // Progress updates should include the resumed counts in message + expect(onProgressMock).toHaveBeenCalled() + }) + + test('checkpoints progress BEFORE kicking member (crash-safety)', async () => { + const callOrder: string[] = [] + + const kickMember = jest.fn(async () => { + callOrder.push('kick') + }) + + getClientMock.mockReturnValue( + makeClient([member('100', { kick: kickMember })]), + ) + + const onProgressMock = jest.fn(async () => { + callOrder.push('progress') + }) + + await new BulkKickExecutor().execute( + JOB({ totalItems: 1 }), + onProgressMock, + ) + + // Verify that progress is called before the kick happens in the loop + expect(onProgressMock).toHaveBeenCalled() + expect(kickMember).toHaveBeenCalled() + // The order should be: progress checkpoint is called, then kick is attempted + const progressIndex = callOrder.indexOf('progress') + const kickIndex = callOrder.indexOf('kick') + expect(progressIndex).toBeGreaterThanOrEqual(0) + expect(kickIndex).toBeGreaterThanOrEqual(0) + expect(progressIndex).toBeLessThan(kickIndex) + }) + + test('pauses when Discord client becomes unavailable mid-run', async () => { + let clientAvailable = true + const clientMockImpl = jest.fn(() => + clientAvailable ? makeClient([member('100'), member('200')]) : null, + ) + getClientMock.mockImplementation(clientMockImpl) + + // First call returns the client; second onwards the client becomes null + batchJobServiceMock.getById + .mockResolvedValueOnce({ nextCursor: null, status: 'in_progress' }) + .mockImplementation(async () => { + // After first iteration, client unavailable + if (clientMockImpl.mock.calls.length > 1) { + clientAvailable = false + } + return { status: 'in_progress' } + }) + + const result = await new BulkKickExecutor().execute( + JOB({ totalItems: 2 }), + jest.fn() as any, + ) + + expect(result).toMatchObject({ paused: true }) + }) }) diff --git a/packages/bot/src/functions/moderation/commands/bulkKick.spec.ts b/packages/bot/src/functions/moderation/commands/bulkKick.spec.ts new file mode 100644 index 000000000..5291d26ae --- /dev/null +++ b/packages/bot/src/functions/moderation/commands/bulkKick.spec.ts @@ -0,0 +1,293 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' +import { Collection } from 'discord.js' + +const batchJobServiceMock = { + create: jest.fn(), +} +const checkBatchPermissionsMock = jest.fn() +const enqueueBatchJobMock = jest.fn() +const showBatchConfirmationMock = jest.fn() +const infoLogMock = jest.fn() +const errorLogMock = jest.fn() +const interactionReplyMock = jest.fn() + +jest.mock('@lucky/shared/services/batch', () => ({ + batchJobService: batchJobServiceMock, + checkBatchPermissions: (...args: any[]) => + checkBatchPermissionsMock(...args), +})) + +jest.mock('../../../utils/batch/batchQueue', () => ({ + enqueueBatchJob: (...args: any[]) => enqueueBatchJobMock(...args), +})) + +jest.mock('../../../utils/batch/confirmationGate', () => ({ + showBatchConfirmation: (...args: any[]) => + showBatchConfirmationMock(...args), +})) + +jest.mock('@lucky/shared/utils', () => ({ + infoLog: (...args: any[]) => infoLogMock(...args), + errorLog: (...args: any[]) => errorLogMock(...args), +})) + +jest.mock('../../../utils/general/interactionReply', () => ({ + interactionReply: (...args: any[]) => interactionReplyMock(...args), +})) + +jest.mock('../batch/bulkKickExecutor', () => { + class MockBulkKickExecutor { + estimateMinutes = jest.fn(() => 10) + } + return { + BulkKickExecutor: MockBulkKickExecutor, + } +}) + +// Import AFTER mocks +import bulkKickCommand from './bulkKick' + +function createMockUser(id = 'user-123', tag = 'TestUser#1234') { + return { + id, + tag, + } +} + +function createMockRole(id = 'role-123', name = 'TestRole') { + return { + id, + name, + members: new Collection(), + } +} + +function createMockMember() { + return { + permissions: { + has: jest.fn(() => true), + }, + } +} + +function createMockGuild(id = 'guild-123', name = 'Test Guild') { + return { + id, + name, + members: { + me: createMockMember(), + fetch: jest.fn().mockResolvedValue(undefined), + }, + } +} + +function createInteraction({ + guildId = 'guild-123', + guild = undefined as any, + userId = 'user-123', + user = null as any, + role = null as any, + reason = null as string | null, + dryRun = false, +} = {}) { + const interaction = { + guild: guild !== undefined ? guild : createMockGuild(guildId), + guildId, + user: user || createMockUser(userId), + options: { + getRole: jest.fn((name: string) => (name === 'role' ? role : null)), + getString: jest.fn((name: string) => + name === 'reason' ? reason : null, + ), + getBoolean: jest.fn((name: string) => + name === 'dry_run' ? dryRun : null, + ), + }, + deferReply: jest.fn().mockResolvedValue(undefined), + editReply: jest.fn().mockResolvedValue(undefined), + } + + return interaction as any +} + +describe('bulkKick command', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Default mocks + checkBatchPermissionsMock.mockReturnValue({ + allowed: true, + missing: [], + }) + enqueueBatchJobMock.mockResolvedValue(true) + showBatchConfirmationMock.mockResolvedValue(true) + batchJobServiceMock.create.mockResolvedValue({ + id: 'job-123', + }) + }) + + test('returns early if not in a guild', async () => { + const interaction = createInteraction({ guild: null }) + await bulkKickCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('must be run in a guild'), + }), + }), + ) + }) + + test('returns early when permission check fails', async () => { + checkBatchPermissionsMock.mockReturnValue({ + allowed: false, + missing: ['KickMembers'], + }) + const interaction = createInteraction({ + role: createMockRole(), + }) + + await bulkKickCommand.execute({ interaction }) + + expect(interactionReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.objectContaining({ + content: expect.stringContaining('Permission check failed'), + }), + }), + ) + }) + + test('returns early when no members hold the target role', async () => { + const interaction = createInteraction({ + role: createMockRole('role-123', 'empty-role'), + }) + + await bulkKickCommand.execute({ interaction }) + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('No non-bot members'), + }), + ) + }) + + test('reports count on dry-run without queueing', async () => { + const role = createMockRole() + role.members.set('u1', { user: { bot: false } } as any) + role.members.set('u2', { user: { bot: false } } as any) + const interaction = createInteraction({ + role, + dryRun: true, + }) + + await bulkKickCommand.execute({ interaction }) + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Dry Run'), + }), + ) + expect(batchJobServiceMock.create).not.toHaveBeenCalled() + }) + + test('cancels operation when user rejects confirmation', async () => { + showBatchConfirmationMock.mockResolvedValue(false) + const role = createMockRole() + role.members.set('u1', { user: { bot: false } } as any) + const interaction = createInteraction({ role }) + + await bulkKickCommand.execute({ interaction }) + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('cancelled'), + }), + ) + expect(batchJobServiceMock.create).not.toHaveBeenCalled() + }) + + test('returns failure reply when enqueueBatchJob returns null (Redis unavailable)', async () => { + enqueueBatchJobMock.mockResolvedValue(null) + const role = createMockRole() + role.members.set('u1', { user: { bot: false } } as any) + const interaction = createInteraction({ role }) + + await bulkKickCommand.execute({ interaction }) + + // Verify failure reply is sent + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining( + 'Failed to queue bulk kick job', + ), + }), + ) + // Verify the job was created (but enqueue failed) + expect(batchJobServiceMock.create).toHaveBeenCalled() + // Verify no info log about success + expect(infoLogMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Bulk kick job created'), + }), + ) + }) + + test('queues bulk kick job successfully when all permissions ok and enqueue succeeds', async () => { + const role = createMockRole('role-456') + role.members.set('u1', { user: { bot: false } } as any) + role.members.set('u2', { user: { bot: false } } as any) + const interaction = createInteraction({ + role, + reason: 'raid cleanup', + }) + + await bulkKickCommand.execute({ interaction }) + + // Verify job was created with correct params + expect(batchJobServiceMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + jobType: 'bulk_kick', + totalItems: 2, + options: expect.objectContaining({ + roleId: 'role-456', + reason: 'raid cleanup', + }), + }), + ) + + // Verify enqueue was called + expect(enqueueBatchJobMock).toHaveBeenCalledWith('job-123') + + // Verify success reply + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Bulk kick queued'), + }), + ) + + // Verify info log + expect(infoLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Bulk kick job created'), + }), + ) + }) + + test('handles exception during job creation', async () => { + batchJobServiceMock.create.mockRejectedValue(new Error('DB error')) + const role = createMockRole() + role.members.set('u1', { user: { bot: false } } as any) + const interaction = createInteraction({ role }) + + await bulkKickCommand.execute({ interaction }) + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Failed to create batch job'), + }), + ) + expect(errorLogMock).toHaveBeenCalled() + }) +}) diff --git a/packages/bot/src/workers/batchJobWorker.spec.ts b/packages/bot/src/workers/batchJobWorker.spec.ts index 7e25a64f8..906b41d71 100644 --- a/packages/bot/src/workers/batchJobWorker.spec.ts +++ b/packages/bot/src/workers/batchJobWorker.spec.ts @@ -34,6 +34,8 @@ function makeModule(opts: { : jest.fn().mockResolvedValue(undefined) const mockMarkInProgress = jest.fn().mockResolvedValue(undefined) const mockMarkCompleted = jest.fn().mockResolvedValue(undefined) + const mockMarkCancelled = jest.fn().mockResolvedValue(undefined) + const mockMarkPaused = jest.fn().mockResolvedValue(undefined) const mockMarkFailed = jest.fn().mockResolvedValue(undefined) const mockSetSummary = jest.fn().mockResolvedValue(undefined) const mockGetById = jest.fn().mockResolvedValue(dbJob) @@ -103,6 +105,8 @@ function makeModule(opts: { markInProgress: mockMarkInProgress, markCompleted: mockMarkCompleted, markFailed: mockMarkFailed, + markCancelled: mockMarkCancelled, + markPaused: mockMarkPaused, setSummary: mockSetSummary, checkpoint: mockCheckpoint, }, @@ -152,6 +156,8 @@ function makeModule(opts: { mockGetById, mockMarkInProgress, mockMarkCompleted, + mockMarkCancelled, + mockMarkPaused, mockMarkFailed, mockSetSummary, mockCheckpoint, @@ -422,4 +428,74 @@ describe('batchJobWorker', () => { await expect(processor(makeJob())).rejects.toThrow('DB error') }) }) + + it('calls markCancelled when executor summary.cancelled=true', async () => { + const dbJob = { + id: 'job-abc', + guildId: 'g1', + jobType: 'bulk_move_messages', + totalItems: 10, + options: {}, + } + const cancellingExecute = jest + .fn() + .mockResolvedValue({ cancelled: true, moved: 0 }) + const { mod, capturedProcessor, mockMarkCancelled } = makeModule({ + dbJob, + executorExecute: cancellingExecute, + }) + await mod.startBatchJobWorker() + const processor = capturedProcessor()! + await processor(makeJob()) + expect(mockMarkCancelled).toHaveBeenCalledWith('job-abc') + }) + + it('calls markPaused when executor summary.paused=true', async () => { + const dbJob = { + id: 'job-abc', + guildId: 'g1', + jobType: 'bulk_move_messages', + totalItems: 10, + options: {}, + } + const pausingExecute = jest + .fn() + .mockResolvedValue({ paused: true, moved: 5 }) + const { mod, capturedProcessor, mockMarkPaused } = makeModule({ + dbJob, + executorExecute: pausingExecute, + }) + await mod.startBatchJobWorker() + const processor = capturedProcessor()! + await processor(makeJob()) + expect(mockMarkPaused).toHaveBeenCalledWith('job-abc') + }) + + it('calls markCompleted for normal completion (no cancelled/paused)', async () => { + const dbJob = { + id: 'job-abc', + guildId: 'g1', + jobType: 'bulk_move_messages', + totalItems: 10, + options: {}, + } + const normalExecute = jest.fn().mockResolvedValue({ moved: 10 }) + const { + mod, + capturedProcessor, + mockMarkCompleted, + mockMarkCancelled, + mockMarkPaused, + } = makeModule({ + dbJob, + executorExecute: normalExecute, + }) + await mod.startBatchJobWorker() + const processor = capturedProcessor()! + await processor(makeJob()) + expect(mockMarkCompleted).toHaveBeenCalledWith('job-abc') + // Verify alternative branches are not taken + expect(mockMarkCancelled).not.toHaveBeenCalled() + expect(mockMarkPaused).not.toHaveBeenCalled() + }) })