feat: add moderation unit tests and refactor warning logic
This commit is contained in:
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 { 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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user