2 Commits

Author SHA1 Message Date
syntaxbullet
c807fd4fd0 test: fix lint errors in moderation service tests 2026-01-06 18:05:05 +01:00
syntaxbullet
47b980eff1 feat: add moderation unit tests and refactor warning logic 2026-01-06 18:03:36 +01:00
3 changed files with 379 additions and 58 deletions

View File

@@ -50,65 +50,25 @@ 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 }
});
// 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.`
@@ -116,10 +76,6 @@ export const warn = createCommand({
flags: MessageFlags.Ephemeral
});
}
} catch (error) {
console.error('Failed to auto-timeout user:', error);
}
}
} catch (error) {
console.error("Warn command error:", error);

View 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);
});
});
});

View File

@@ -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<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
*/