feat: standardize command error handling (Sprint 4)

- Create withCommandErrorHandling utility in bot/lib/commandUtils.ts
- Migrate economy commands: daily, exam, pay, trivia
- Migrate inventory command: use
- Migrate admin/moderation commands: warn, case, cases, clearwarning,
  warnings, note, notes, create_color, listing, webhook, refresh,
  terminal, featureflags, settings, prune
- Add 9 unit tests for the utility
- Update AGENTS.md with new recommended error handling pattern
This commit is contained in:
syntaxbullet
2026-02-13 14:23:37 +01:00
parent 0c67a8754f
commit 141c3098f8
23 changed files with 990 additions and 834 deletions

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
import { UserError } from "@shared/lib/errors";
// --- Mocks ---
const mockDeferReply = mock(() => Promise.resolve());
const mockEditReply = mock(() => Promise.resolve());
const mockInteraction = {
deferReply: mockDeferReply,
editReply: mockEditReply,
} as any;
const mockCreateErrorEmbed = mock((msg: string) => ({ description: msg, type: "error" }));
mock.module("./embeds", () => ({
createErrorEmbed: mockCreateErrorEmbed,
}));
// Import AFTER mocking
const { withCommandErrorHandling } = await import("./commandUtils");
// --- Tests ---
describe("withCommandErrorHandling", () => {
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockDeferReply.mockClear();
mockEditReply.mockClear();
mockCreateErrorEmbed.mockClear();
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => { });
});
it("should always call deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result"
);
expect(mockDeferReply).toHaveBeenCalledTimes(1);
});
it("should pass ephemeral option to deferReply", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ ephemeral: true }
);
expect(mockDeferReply).toHaveBeenCalledWith({ ephemeral: true });
});
it("should return the operation result on success", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => ({ data: "test" })
);
expect(result).toEqual({ data: "test" });
});
it("should call onSuccess with the result", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "hello",
{ onSuccess }
);
expect(onSuccess).toHaveBeenCalledWith("hello");
});
it("should send successMessage when no onSuccess is provided", async () => {
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "It worked!" }
);
expect(mockEditReply).toHaveBeenCalledWith({
content: "It worked!",
});
});
it("should prefer onSuccess over successMessage", async () => {
const onSuccess = mock(async (_result: string) => { });
await withCommandErrorHandling(
mockInteraction,
async () => "result",
{ successMessage: "This should not be sent", onSuccess }
);
expect(onSuccess).toHaveBeenCalledTimes(1);
// editReply should NOT have been called with the successMessage
expect(mockEditReply).not.toHaveBeenCalledWith({
content: "This should not be sent",
});
});
it("should show error embed for UserError", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new UserError("You can't do that!");
}
);
expect(result).toBeUndefined();
expect(mockCreateErrorEmbed).toHaveBeenCalledWith("You can't do that!");
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should show generic error and log for unexpected errors", async () => {
const unexpectedError = new Error("Database exploded");
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw unexpectedError;
}
);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Unexpected error in command:",
unexpectedError
);
expect(mockCreateErrorEmbed).toHaveBeenCalledWith(
"An unexpected error occurred."
);
expect(mockEditReply).toHaveBeenCalledTimes(1);
});
it("should return undefined on error", async () => {
const result = await withCommandErrorHandling(
mockInteraction,
async () => {
throw new Error("fail");
}
);
expect(result).toBeUndefined();
});
});

79
bot/lib/commandUtils.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "./embeds";
/**
* Wraps a command's core logic with standardized error handling.
*
* - Calls `interaction.deferReply()` automatically
* - On success, invokes `onSuccess` callback or sends `successMessage`
* - On `UserError`, shows the error message in an error embed
* - On unexpected errors, logs to console and shows a generic error embed
*
* @example
* ```typescript
* export const myCommand = createCommand({
* execute: async (interaction) => {
* await withCommandErrorHandling(
* interaction,
* async () => {
* const result = await doSomething();
* await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
* }
* );
* }
* });
* ```
*
* @example
* ```typescript
* // With deferReply options (e.g. ephemeral)
* await withCommandErrorHandling(
* interaction,
* async () => doSomething(),
* {
* ephemeral: true,
* successMessage: "Done!",
* }
* );
* ```
*/
export async function withCommandErrorHandling<T>(
interaction: ChatInputCommandInteraction,
operation: () => Promise<T>,
options?: {
/** Message to send on success (if no onSuccess callback is provided) */
successMessage?: string;
/** Callback invoked with the operation result on success */
onSuccess?: (result: T) => Promise<void>;
/** Whether the deferred reply should be ephemeral */
ephemeral?: boolean;
}
): Promise<T | undefined> {
try {
await interaction.deferReply({ ephemeral: options?.ephemeral });
const result = await operation();
if (options?.onSuccess) {
await options.onSuccess(result);
} else if (options?.successMessage) {
await interaction.editReply({
content: options.successMessage,
});
}
return result;
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)],
});
} else {
console.error("Unexpected error in command:", error);
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred.")],
});
}
return undefined;
}
}