From ebc39993cf9f8ed9baffcfaedabc83b2b198e079 Mon Sep 17 00:00:00 2001 From: Lars Knol Date: Tue, 12 Sep 2023 16:51:00 +0200 Subject: [PATCH] feat: implement delete note command (#72) --- .../chatinput/cases/notes/delete/index.ts | 102 ++++++++++++++ .../commands/chatinput/cases/notes/index.ts | 3 +- .../cases/notes/delete/index.test.ts | 127 ++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 apps/barry/src/modules/moderation/commands/chatinput/cases/notes/delete/index.ts create mode 100644 apps/barry/tests/modules/moderation/commands/chatinput/cases/notes/delete/index.test.ts diff --git a/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/delete/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/delete/index.ts new file mode 100644 index 0000000..795f04a --- /dev/null +++ b/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/delete/index.ts @@ -0,0 +1,102 @@ +import { + type ApplicationCommandInteraction, + SlashCommand, + SlashCommandOptionBuilder +} from "@barry/core"; +import type ModerationModule from "../../../../../index.js"; + +import { MessageFlags, PermissionFlagsBits } from "@discordjs/core"; +import config from "../../../../../../../config.js"; + +/** + * Options for the delete note options. + */ +export interface DeleteNoteOptions { + /** + * The ID of the case. + */ + case: number; + + /** + * The ID of the note. + */ + note: number; +} + +/** + * The permissions needed to delete someone else's note. + */ +const BYPASS_PERMISSIONS = PermissionFlagsBits.Administrator | PermissionFlagsBits.ManageGuild; + +/** + * Represents a slash command that a note from a case. + */ +export default class extends SlashCommand { + /** + * Represents a slash command that a note from a case. + * + * @param module The module this command belongs to. + */ + constructor(module: ModerationModule) { + super(module, { + name: "delete", + description: "Removes a note from a case.", + defaultMemberPermissions: PermissionFlagsBits.ModerateMembers, + guildOnly: true, + options: { + case: SlashCommandOptionBuilder.integer({ + description: "The ID of the case.", + minimum: 1, + required: true + }), + note: SlashCommandOptionBuilder.integer({ + description: "The ID of the note.", + minimum: 1, + required: true + }) + } + }); + } + + /** + * Remove a note from a case. + * + * @param interaction The interaction that triggered the command. + * @param options The options for the command. + */ + async execute(interaction: ApplicationCommandInteraction, options: DeleteNoteOptions): Promise { + if (!interaction.isInvokedInGuild()) { + return; + } + + const entity = await this.module.cases.get(interaction.guildID, options.case, true); + if (entity === null) { + return interaction.createMessage({ + content: `${config.emotes.error} That case does not exist.`, + flags: MessageFlags.Ephemeral + }); + } + + const note = entity.notes.find((n) => n.id === options.note); + if (note === undefined) { + return interaction.createMessage({ + content: `${config.emotes.error} That note does not exist.`, + flags: MessageFlags.Ephemeral + }); + } + + const hasPermissions = (BigInt(interaction.member.permissions) & BYPASS_PERMISSIONS) !== 0n; + if (!hasPermissions && note.creatorID !== interaction.user.id) { + return interaction.createMessage({ + content: `${config.emotes.error} That is not your note.`, + flags: MessageFlags.Ephemeral + }); + } + + await this.module.caseNotes.delete(interaction.guildID, options.case, options.note); + await interaction.createMessage({ + content: `${config.emotes.check} Successfully deleted note \`${options.note}\` from case \`${options.case}\`.`, + flags: MessageFlags.Ephemeral + }); + } +} diff --git a/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/index.ts index 42135fd..20e926d 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/cases/notes/index.ts @@ -3,6 +3,7 @@ import type ModerationModule from "../../../../index.js"; import { PermissionFlagsBits } from "@discordjs/core"; import { SlashCommand } from "@barry/core"; import AddNoteCommand from "./add/index.js"; +import DeleteNoteCommand from "./delete/index.js"; /** * Represents a slash command to manage notes on a case. @@ -19,7 +20,7 @@ export default class extends SlashCommand { description: "Modify or add notes to a case.", defaultMemberPermissions: PermissionFlagsBits.ModerateMembers, guildOnly: true, - children: [AddNoteCommand] + children: [AddNoteCommand, DeleteNoteCommand] }); } diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/cases/notes/delete/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/cases/notes/delete/index.test.ts new file mode 100644 index 0000000..df36f4d --- /dev/null +++ b/apps/barry/tests/modules/moderation/commands/chatinput/cases/notes/delete/index.test.ts @@ -0,0 +1,127 @@ +import type { CaseWithNotes } from "../../../../../../../../src/modules/moderation/database.js"; + +import { MessageFlags, PermissionFlagsBits } from "@discordjs/core"; +import { + createMockApplicationCommandInteraction, + mockMember, + mockUser +} from "@barry/testing"; +import { mockCase, mockCaseNote } from "../../../../../mocks/case.js"; +import { ApplicationCommandInteraction } from "@barry/core"; +import { createMockApplication } from "../../../../../../../mocks/application.js"; + +import DeleteNoteCommand, { type DeleteNoteOptions } from "../../../../../../../../src/modules/moderation/commands/chatinput/cases/notes/delete/index.js"; +import ModerationModule from "../../../../../../../../src/modules/moderation/index.js"; + +describe("/cases notes delete", () => { + let command: DeleteNoteCommand; + let interaction: ApplicationCommandInteraction; + let options: DeleteNoteOptions; + + beforeEach(() => { + const client = createMockApplication(); + const module = new ModerationModule(client); + command = new DeleteNoteCommand(module); + + const data = createMockApplicationCommandInteraction(); + interaction = new ApplicationCommandInteraction(data, client, vi.fn()); + + options = { + case: 34, + note: 1 + }; + + vi.spyOn(module.cases, "get").mockResolvedValue({ + ...mockCase, + notes: [mockCaseNote] + } as CaseWithNotes); + vi.spyOn(module.caseNotes, "delete").mockResolvedValue(mockCaseNote); + }); + + describe("execute", () => { + it("should ignore if the interaction was invoked outside a guild", async () => { + delete interaction.guildID; + + await command.execute(interaction, options); + + expect(interaction.acknowledged).toBe(false); + }); + + it("should show an error message if the provided case does not exist", async () => { + vi.spyOn(command.module.cases, "get").mockResolvedValue(null); + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("That case does not exist."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show an error message if the provided note does not exist", async () => { + vi.spyOn(command.module.cases, "get").mockResolvedValue({ ...mockCase, notes: [] } as CaseWithNotes); + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("That note does not exist."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show an error message if the user tries deleting a note that is not theirs", async () => { + interaction.user = { ...mockUser, id: mockCase.userID }; + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("That is not your note."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should delete the provided note", async () => { + const deleteSpy = vi.spyOn(command.module.caseNotes, "delete"); + + await command.execute(interaction, options); + + expect(deleteSpy).toHaveBeenCalledOnce(); + expect(deleteSpy).toHaveBeenCalledWith(interaction.guildID, options.case, options.note); + }); + + it("should bypass if the user has administrator permissions", async () => { + interaction.user = { ...mockUser, id: mockCase.userID }; + interaction.member = { + ...mockMember, + permissions: PermissionFlagsBits.ManageGuild.toString() + }; + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("Successfully deleted note `1` from case `34`."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show a success message if the note is successfully deleted", async () => { + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("Successfully deleted note `1` from case `34`."), + flags: MessageFlags.Ephemeral + }); + }); + }); +});