diff --git a/src/commands/admin/warn.ts b/src/commands/admin/warn.ts index d6d4ca0..770ccbe 100644 --- a/src/commands/admin/warn.ts +++ b/src/commands/admin/warn.ts @@ -50,75 +50,31 @@ export const warn = createCommand({ return; } - // Create the warning case - const moderationCase = await ModerationService.createCase({ - type: 'warn', + // Issue the warning via service + const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({ userId: targetUser.id, username: targetUser.username, moderatorId: interaction.user.id, moderatorName: interaction.user.username, reason, + guildName: interaction.guild?.name || undefined, + dmTarget: targetUser, + timeoutTarget: await interaction.guild?.members.fetch(targetUser.id) }); - if (!moderationCase) { - await interaction.editReply({ - embeds: [getModerationErrorEmbed("Failed to create warning case.")] - }); - return; - } - - // Get total warning count for the user - const warningCount = await ModerationService.getActiveWarningCount(targetUser.id); - // Send success message to moderator await interaction.editReply({ embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)] }); - // Try to DM the user if configured - if (config.moderation.cases.dmOnWarn) { - try { - const serverName = interaction.guild?.name || 'this server'; - await targetUser.send({ - embeds: [getUserWarningEmbed(serverName, reason, moderationCase.caseId, warningCount)] - }); - } catch (error) { - // Silently fail if user has DMs disabled - console.log(`Could not DM warning to ${targetUser.username}: ${error}`); - } - } - - // Optional: Check for auto-timeout threshold - if (config.moderation.cases.autoTimeoutThreshold && - warningCount >= config.moderation.cases.autoTimeoutThreshold) { - - try { - const member = await interaction.guild?.members.fetch(targetUser.id); - if (member) { - // Auto-timeout for 24 hours (86400000 ms) - await member.timeout(86400000, `Automatic timeout: ${warningCount} warnings`); - - // Create a timeout case - await ModerationService.createCase({ - type: 'timeout', - userId: targetUser.id, - username: targetUser.username, - moderatorId: interaction.client.user!.id, - moderatorName: interaction.client.user!.username, - reason: `Automatic timeout: reached ${warningCount} warnings`, - metadata: { duration: '24h', automatic: true } - }); - - await interaction.followUp({ - embeds: [getModerationErrorEmbed( - `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.` - )], - flags: MessageFlags.Ephemeral - }); - } - } catch (error) { - console.error('Failed to auto-timeout user:', error); - } + // Follow up if auto-timeout was issued + if (autoTimeoutIssued) { + await interaction.followUp({ + embeds: [getModerationErrorEmbed( + `⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.` + )], + flags: MessageFlags.Ephemeral + }); } } catch (error) { diff --git a/src/modules/moderation/moderation.service.test.ts b/src/modules/moderation/moderation.service.test.ts new file mode 100644 index 0000000..57c1486 --- /dev/null +++ b/src/modules/moderation/moderation.service.test.ts @@ -0,0 +1,290 @@ + +import { describe, it, expect, mock, beforeEach } from "bun:test"; +import { ModerationService } from "./moderation.service"; +import { moderationCases } from "@/db/schema"; + +// Mock Drizzle Functions +const mockFindFirst = mock(); +const mockFindMany = mock(); +const mockInsert = mock(); +const mockUpdate = mock(); +const mockValues = mock(); +const mockReturning = mock(); +const mockSet = mock(); +const mockWhere = mock(); + +// Mock Config +const mockConfig = { + moderation: { + cases: { + dmOnWarn: true, + autoTimeoutThreshold: 3 + } + } +}; + +mock.module("@/lib/config", () => ({ + config: mockConfig +})); + +// Mock View +const mockGetUserWarningEmbed = mock(() => ({})); +mock.module("./moderation.view", () => ({ + getUserWarningEmbed: mockGetUserWarningEmbed +})); + +// Mock DrizzleClient +mock.module("@/lib/DrizzleClient", () => ({ + DrizzleClient: { + query: { + moderationCases: { + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + }, + insert: mockInsert, + update: mockUpdate, + } +})); + +// Setup chains +mockInsert.mockReturnValue({ values: mockValues }); +mockValues.mockReturnValue({ returning: mockReturning }); +mockUpdate.mockReturnValue({ set: mockSet }); +mockSet.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ returning: mockReturning }); + +describe("ModerationService", () => { + beforeEach(() => { + mockFindFirst.mockReset(); + mockFindMany.mockReset(); + mockInsert.mockClear(); + mockUpdate.mockClear(); + mockValues.mockClear(); + mockReturning.mockClear(); + mockSet.mockClear(); + mockWhere.mockClear(); + mockGetUserWarningEmbed.mockClear(); + // Reset config to defaults + mockConfig.moderation.cases.dmOnWarn = true; + mockConfig.moderation.cases.autoTimeoutThreshold = 3; + }); + + describe("issueWarning", () => { + const defaultOptions = { + userId: "123456789", + username: "testuser", + moderatorId: "987654321", + moderatorName: "mod", + reason: "test reason", + guildName: "Test Guild" + }; + + it("should issue a warning and attempt to DM the user", async () => { + mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); + mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]); + mockFindMany.mockResolvedValue([{ type: 'warn', active: true }]); // 1 warning total + + const mockDmTarget = { send: mock() }; + + const result = await ModerationService.issueWarning({ + ...defaultOptions, + dmTarget: mockDmTarget + }); + + expect(result.moderationCase).toBeDefined(); + expect(result.warningCount).toBe(1); + expect(mockDmTarget.send).toHaveBeenCalled(); + expect(mockGetUserWarningEmbed).toHaveBeenCalled(); + }); + + it("should not DM if dmOnWarn is false", async () => { + mockConfig.moderation.cases.dmOnWarn = false; + mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); + mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]); + mockFindMany.mockResolvedValue([]); + + const mockDmTarget = { send: mock() }; + + await ModerationService.issueWarning({ + ...defaultOptions, + dmTarget: mockDmTarget + }); + + expect(mockDmTarget.send).not.toHaveBeenCalled(); + }); + + it("should trigger auto-timeout when threshold is reached", async () => { + mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); + mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]); + // Simulate 3 warnings (threshold is 3) + mockFindMany.mockResolvedValue([{}, {}, {}]); + + const mockTimeoutTarget = { timeout: mock() }; + + const result = await ModerationService.issueWarning({ + ...defaultOptions, + timeoutTarget: mockTimeoutTarget + }); + + expect(result.autoTimeoutIssued).toBe(true); + expect(mockTimeoutTarget.timeout).toHaveBeenCalledWith(86400000, expect.stringContaining("3 warnings")); + // Should create two cases: one for warn, one for timeout + expect(mockInsert).toHaveBeenCalledTimes(2); + }); + + it("should not timeout if threshold is not reached", async () => { + mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); + mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]); + // Simulate 2 warnings (threshold is 3) + mockFindMany.mockResolvedValue([{}, {}]); + + const mockTimeoutTarget = { timeout: mock() }; + + const result = await ModerationService.issueWarning({ + ...defaultOptions, + timeoutTarget: mockTimeoutTarget + }); + + expect(result.autoTimeoutIssued).toBe(false); + expect(mockTimeoutTarget.timeout).not.toHaveBeenCalled(); + expect(mockInsert).toHaveBeenCalledTimes(1); + }); + }); + + describe("getNextCaseId", () => { + it("should return CASE-0001 if no cases exist", async () => { + mockFindFirst.mockResolvedValue(undefined); + // Accessing private method via bracket notation for testing + const nextId = await (ModerationService as any).getNextCaseId(); + expect(nextId).toBe("CASE-0001"); + }); + + it("should increment the latest case ID", async () => { + mockFindFirst.mockResolvedValue({ caseId: "CASE-0042" }); + const nextId = await (ModerationService as any).getNextCaseId(); + expect(nextId).toBe("CASE-0043"); + }); + + it("should handle padding correctly (e.g., 9 -> 0010)", async () => { + mockFindFirst.mockResolvedValue({ caseId: "CASE-0009" }); + const nextId = await (ModerationService as any).getNextCaseId(); + expect(nextId).toBe("CASE-0010"); + }); + }); + + describe("createCase", () => { + it("should create a new moderation case with correct values", async () => { + mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" }); + const mockNewCase = { + caseId: "CASE-0002", + type: 'warn', + userId: 123456789n, + username: "testuser", + moderatorId: 987654321n, + moderatorName: "mod", + reason: "test reason", + metadata: {}, + active: true + }; + mockReturning.mockResolvedValue([mockNewCase]); + + const result = await ModerationService.createCase({ + type: 'warn', + userId: "123456789", + username: "testuser", + moderatorId: "987654321", + moderatorName: "mod", + reason: "test reason" + }); + + expect(result.caseId).toBe("CASE-0002"); + expect(mockInsert).toHaveBeenCalled(); + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + caseId: "CASE-0002", + type: 'warn', + userId: 123456789n, + reason: "test reason" + })); + }); + + it("should set active to false for non-warn types", async () => { + mockFindFirst.mockResolvedValue(undefined); + mockReturning.mockImplementation((values) => [values]); // Simplified mock + + const result = await ModerationService.createCase({ + type: 'ban', + userId: "123456789", + username: "testuser", + moderatorId: "987654321", + moderatorName: "mod", + reason: "test reason" + }); + + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + active: false + })); + }); + }); + + describe("getCaseById", () => { + it("should return a case by its ID", async () => { + const mockCase = { caseId: "CASE-0001", reason: "test" }; + mockFindFirst.mockResolvedValue(mockCase); + + const result = await ModerationService.getCaseById("CASE-0001"); + expect(result).toEqual(mockCase as any); + }); + }); + + describe("getUserCases", () => { + it("should return all cases for a user", async () => { + const mockCases = [{ caseId: "CASE-0001" }, { caseId: "CASE-0002" }]; + mockFindMany.mockResolvedValue(mockCases); + + const result = await ModerationService.getUserCases("123456789"); + expect(result).toHaveLength(2); + expect(mockFindMany).toHaveBeenCalled(); + }); + }); + + describe("clearCase", () => { + it("should update a case to be inactive and resolved", async () => { + const mockUpdatedCase = { caseId: "CASE-0001", active: false }; + mockReturning.mockResolvedValue([mockUpdatedCase]); + + const result = await ModerationService.clearCase({ + caseId: "CASE-0001", + clearedBy: "987654321", + clearedByName: "mod", + reason: "resolved" + }); + + expect(result.active).toBe(false); + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ + active: false, + resolvedBy: 987654321n, + resolvedReason: "resolved" + })); + }); + }); + + describe("getActiveWarningCount", () => { + it("should return the number of active warnings", async () => { + mockFindMany.mockResolvedValue([ + { id: 1n, type: 'warn', active: true }, + { id: 2n, type: 'warn', active: true } + ]); + + const count = await ModerationService.getActiveWarningCount("123456789"); + expect(count).toBe(2); + }); + + it("should return 0 if no active warnings", async () => { + mockFindMany.mockResolvedValue([]); + const count = await ModerationService.getActiveWarningCount("123456789"); + expect(count).toBe(0); + }); + }); +}); diff --git a/src/modules/moderation/moderation.service.ts b/src/modules/moderation/moderation.service.ts index e0b825f..5937cd6 100644 --- a/src/modules/moderation/moderation.service.ts +++ b/src/modules/moderation/moderation.service.ts @@ -1,7 +1,9 @@ import { moderationCases } from "@/db/schema"; import { eq, and, desc } from "drizzle-orm"; import { DrizzleClient } from "@/lib/DrizzleClient"; -import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter, CaseType } from "./moderation.types"; +import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types"; +import { config } from "@/lib/config"; +import { getUserWarningEmbed } from "./moderation.view"; export class ModerationService { /** @@ -47,6 +49,79 @@ export class ModerationService { return newCase; } + /** + * Issue a warning with DM and threshold logic + */ + static async issueWarning(options: { + userId: string; + username: string; + moderatorId: string; + moderatorName: string; + reason: string; + guildName?: string; + dmTarget?: { send: (options: any) => Promise }; + timeoutTarget?: { timeout: (duration: number, reason: string) => Promise }; + }) { + const moderationCase = await this.createCase({ + type: 'warn', + userId: options.userId, + username: options.username, + moderatorId: options.moderatorId, + moderatorName: options.moderatorName, + reason: options.reason, + }); + + if (!moderationCase) { + throw new Error("Failed to create moderation case"); + } + + const warningCount = await this.getActiveWarningCount(options.userId); + + // Try to DM the user if configured + if (config.moderation.cases.dmOnWarn && options.dmTarget) { + try { + await options.dmTarget.send({ + embeds: [getUserWarningEmbed( + options.guildName || 'this server', + options.reason, + moderationCase.caseId, + warningCount + )] + }); + } catch (error) { + console.log(`Could not DM warning to ${options.username}: ${error}`); + } + } + + // Check for auto-timeout threshold + let autoTimeoutIssued = false; + if (config.moderation.cases.autoTimeoutThreshold && + warningCount >= config.moderation.cases.autoTimeoutThreshold && + options.timeoutTarget) { + + try { + // Auto-timeout for 24 hours (86400000 ms) + await options.timeoutTarget.timeout(86400000, `Automatic timeout: ${warningCount} warnings`); + + // Create a timeout case + await this.createCase({ + type: 'timeout', + userId: options.userId, + username: options.username, + moderatorId: "0", // System/Bot + moderatorName: "System", + reason: `Automatic timeout: reached ${warningCount} warnings`, + metadata: { duration: '24h', automatic: true } + }); + autoTimeoutIssued = true; + } catch (error) { + console.error('Failed to auto-timeout user:', error); + } + } + + return { moderationCase, warningCount, autoTimeoutIssued }; + } + /** * Get a case by its case ID */