forked from syntaxbullet/AuroraBot-discord
feat: add moderation unit tests and refactor warning logic
This commit is contained in:
@@ -50,75 +50,31 @@ export const warn = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the warning case
|
// Issue the warning via service
|
||||||
const moderationCase = await ModerationService.createCase({
|
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||||
type: 'warn',
|
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
moderatorName: interaction.user.username,
|
moderatorName: interaction.user.username,
|
||||||
reason,
|
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
|
// Send success message to moderator
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
embeds: [getWarnSuccessEmbed(moderationCase.caseId, targetUser.username, reason)]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to DM the user if configured
|
// Follow up if auto-timeout was issued
|
||||||
if (config.moderation.cases.dmOnWarn) {
|
if (autoTimeoutIssued) {
|
||||||
try {
|
await interaction.followUp({
|
||||||
const serverName = interaction.guild?.name || 'this server';
|
embeds: [getModerationErrorEmbed(
|
||||||
await targetUser.send({
|
`⚠️ User has reached ${warningCount} warnings and has been automatically timed out for 24 hours.`
|
||||||
embeds: [getUserWarningEmbed(serverName, reason, moderationCase.caseId, warningCount)]
|
)],
|
||||||
});
|
flags: MessageFlags.Ephemeral
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
290
src/modules/moderation/moderation.service.test.ts
Normal file
290
src/modules/moderation/moderation.service.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { moderationCases } from "@/db/schema";
|
import { moderationCases } from "@/db/schema";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
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 {
|
export class ModerationService {
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +49,79 @@ export class ModerationService {
|
|||||||
return newCase;
|
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<any> };
|
||||||
|
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
||||||
|
}) {
|
||||||
|
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
|
* Get a case by its case ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user