From 6925d3103a6c8ea94d2031c195c09efb81089076 Mon Sep 17 00:00:00 2001 From: HeadTriXz Date: Fri, 15 Sep 2023 16:08:43 +0200 Subject: [PATCH 1/2] feat: implement dwc command --- apps/barry/prisma/schema.prisma | 11 + apps/barry/src/config.ts | 3 + .../dependencies/profiles/database.ts | 32 ++ .../dependencies/profiles/index.ts | 64 +++ .../dependencies/requests/database.ts | 53 ++ .../dependencies/requests/index.ts | 69 ++- apps/barry/src/modules/marketplace/utils.ts | 24 +- .../commands/chatinput/ban/index.ts | 16 +- .../commands/chatinput/dwc/index.ts | 261 +++++++++ .../commands/chatinput/kick/index.ts | 14 +- .../commands/chatinput/mute/index.ts | 16 +- .../commands/chatinput/note/index.ts | 14 +- .../commands/chatinput/unban/index.ts | 14 +- .../commands/chatinput/unmute/index.ts | 14 +- .../commands/chatinput/warn/index.ts | 14 +- .../barry/src/modules/moderation/constants.ts | 4 + apps/barry/src/modules/moderation/database.ts | 108 ++++ apps/barry/src/modules/moderation/index.ts | 199 ++++++- apps/barry/src/modules/moderation/types.ts | 74 +++ .../marketplace/profiles/database.test.ts | 36 ++ .../marketplace/profiles/index.test.ts | 106 ++++ .../marketplace/requests/database.test.ts | 48 ++ .../marketplace/requests/index.test.ts | 106 ++++ .../commands/chatinput/ban/index.test.ts | 14 +- .../commands/chatinput/dwc/index.test.ts | 514 ++++++++++++++++++ .../commands/chatinput/kick/index.test.ts | 14 +- .../commands/chatinput/mute/index.test.ts | 14 +- .../commands/chatinput/note/index.test.ts | 14 +- .../commands/chatinput/unban/index.test.ts | 14 +- .../commands/chatinput/unmute/index.test.ts | 14 +- .../commands/chatinput/warn/index.test.ts | 14 +- .../tests/modules/moderation/database.test.ts | 103 ++++ .../tests/modules/moderation/index.test.ts | 416 +++++++++++++- 33 files changed, 2316 insertions(+), 115 deletions(-) create mode 100644 apps/barry/src/modules/moderation/commands/chatinput/dwc/index.ts create mode 100644 apps/barry/src/modules/moderation/types.ts create mode 100644 apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts diff --git a/apps/barry/prisma/schema.prisma b/apps/barry/prisma/schema.prisma index e97d2e8..ed093c4 100644 --- a/apps/barry/prisma/schema.prisma +++ b/apps/barry/prisma/schema.prisma @@ -161,8 +161,10 @@ enum CaseType { Mute @map("MUTE") Kick @map("KICK") Ban @map("BAN") + DWC Unmute @map("UNMUTE") Unban @map("UNBAN") + UnDWC @map("UNDWC") } model Case { @@ -212,3 +214,12 @@ model TempBan { @@id([guildID, userID]) @@map("temp_bans") } + +model DWCScheduledBan { + guildID String @map("guild_id") + userID String @map("user_id") + createdAt DateTime @map("created_at") @default(now()) + + @@id([guildID, userID]) + @@map("dwc_scheduled_bans") +} diff --git a/apps/barry/src/config.ts b/apps/barry/src/config.ts index 0ee76d3..e8ed10f 100644 --- a/apps/barry/src/config.ts +++ b/apps/barry/src/config.ts @@ -49,10 +49,12 @@ export default { emotes: { // Moderation ban: new Emoji("ban", "1149399269531459616"), + dwc: new Emoji("dwc", "1151425609487106101"), kick: new Emoji("kick", "1149399337412071484"), mute: new Emoji("mute", "1149399536381477006"), note: new Emoji("note", "1149399622398255188"), unban: new Emoji("unban", "1149399596372607127"), + undwc: new Emoji("undwc", "1151870611203821628"), unmute: new Emoji("unmute", "1149399568446914670"), warn: new Emoji("warn", "1149399652504977508"), @@ -76,5 +78,6 @@ export default { previous: new Emoji("previous", "1124406936188768357") }, defaultColor: 0xFFC331, + defaultDWCColor: 0xFFFF58, embedColor: 0x2B2D31 }; diff --git a/apps/barry/src/modules/marketplace/dependencies/profiles/database.ts b/apps/barry/src/modules/marketplace/dependencies/profiles/database.ts index 507195b..a779524 100644 --- a/apps/barry/src/modules/marketplace/dependencies/profiles/database.ts +++ b/apps/barry/src/modules/marketplace/dependencies/profiles/database.ts @@ -135,6 +135,38 @@ export class ProfileRepository { }); } + /** + * Retrieves the profile record with the flaggable messages for the specified user. + * + * @param guildID The ID of the guild. + * @param userID The ID of the user. + * @param maxDays The amount of days to get profiles for. + * @returns The profile record with messages, or null if not found. + */ + async getWithFlaggableMessages( + guildID: string, + userID: string, + maxDays: number = 14 + ): Promise { + const milliseconds = maxDays * 86400000; + const timestamp = BigInt(Date.now() - milliseconds - 1420070400000); + const minimumID = String(timestamp << 22n); + + return this.#prisma.profile.findUnique({ + include: { + messages: { + where: { + guildID: guildID, + messageID: { + gte: minimumID + } + } + } + }, + where: { userID } + }); + } + /** * Retrieves the profile record with its messages for the specified user. * diff --git a/apps/barry/src/modules/marketplace/dependencies/profiles/index.ts b/apps/barry/src/modules/marketplace/dependencies/profiles/index.ts index cf75caa..4b17b88 100644 --- a/apps/barry/src/modules/marketplace/dependencies/profiles/index.ts +++ b/apps/barry/src/modules/marketplace/dependencies/profiles/index.ts @@ -1,4 +1,5 @@ import { + type APIEmbed, type APIUser, ButtonStyle, ComponentType @@ -11,7 +12,10 @@ import { ProfileRepository, ProfilesSettingsRepository } from "./database.js"; + +import { DiscordAPIError } from "@discordjs/rest"; import { Module } from "@barry/core"; +import { getDWCEmbed } from "../../utils.js"; import { getProfileContent } from "./editor/functions/content.js"; import { loadEvents } from "../../../../utils/loadFolder.js"; @@ -69,6 +73,18 @@ export default class ProfilesModule extends Module { this.profilesSettings = new ProfilesSettingsRepository(client.prisma); } + /** + * Flags all requests for the specified user. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user to flag the requests of. + * @param reason The reason to flag the user. + */ + async flagUser(guildID: string, channelID: string, user: APIUser, reason: string): Promise { + return this.#resetProfiles(guildID, channelID, user, 14, [getDWCEmbed(reason)]); + } + /** * Checks if the guild has enabled this module. * @@ -148,4 +164,52 @@ export default class ProfilesModule extends Module { } } } + + /** + * Removes the flag from all profiles for the specified user. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user to remove the flag of. + */ + async unflagUser(guildID: string, channelID: string, user: APIUser): Promise { + return this.#resetProfiles(guildID, channelID, user, 21); + } + + /** + * Resets flagged profiles for a user in a specific guild's channel. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user for whom the profiles are being reset. + * @param maxDays The maximum number of days ago a request can be to be reset. + * @param embeds Optional array of embed objects to include in the updated messages. + */ + async #resetProfiles( + guildID: string, + channelID: string, + user: APIUser, + maxDays: number = 14, + embeds: APIEmbed[] = [] + ): Promise { + const profile = await this.profiles.getWithFlaggableMessages(guildID, user.id, maxDays); + if (profile !== null) { + const content = getProfileContent(user, profile); + if (embeds.length > 0) { + content.embeds?.push(...embeds); + } + + for (const message of profile.messages) { + try { + await this.client.api.channels.editMessage(channelID, message.messageID, content); + } catch (error: unknown) { + if (error instanceof DiscordAPIError && error.code === 10008) { + continue; + } + + this.client.logger.error(error); + } + } + } + } } diff --git a/apps/barry/src/modules/marketplace/dependencies/requests/database.ts b/apps/barry/src/modules/marketplace/dependencies/requests/database.ts index c5285e0..f61c6cd 100644 --- a/apps/barry/src/modules/marketplace/dependencies/requests/database.ts +++ b/apps/barry/src/modules/marketplace/dependencies/requests/database.ts @@ -18,6 +18,16 @@ export interface RequestWithAttachments extends Request { attachments: RequestAttachment[]; } +/** + * Represents a request with messages. + */ +export interface RequestWithMessages extends Request { + /** + * The messages for the request. + */ + messages: RequestMessage[]; +} + /** * Repository class for managing requests. */ @@ -123,6 +133,49 @@ export class RequestRepository { }); } + /** + * Retrieves the requests that can be flagged for the specified user. + * + * @param guildID The ID of the guild. + * @param userID The ID of the user. + * @param maxDays The amount of days to get requests for. + * @returns The flaggable request records. + */ + async getFlaggableByUser( + guildID: string, + userID: string, + maxDays: number = 14 + ): Promise> { + const milliseconds = maxDays * 86400000; + const timestamp = BigInt(Date.now() - milliseconds - 1420070400000); + const minimumID = String(timestamp << 22n); + + return this.#prisma.request.findMany({ + include: { + attachments: true, + messages: { + where: { + guildID: guildID, + messageID: { + gte: minimumID + } + } + } + }, + where: { + messages: { + some: { + guildID: guildID, + messageID: { + gte: minimumID + } + } + }, + userID: userID + } + }); + } + /** * Upserts a request record for the specified user. * diff --git a/apps/barry/src/modules/marketplace/dependencies/requests/index.ts b/apps/barry/src/modules/marketplace/dependencies/requests/index.ts index a837c91..72668da 100644 --- a/apps/barry/src/modules/marketplace/dependencies/requests/index.ts +++ b/apps/barry/src/modules/marketplace/dependencies/requests/index.ts @@ -1,4 +1,9 @@ -import { type APIUser, ButtonStyle, ComponentType } from "@discordjs/core"; +import { + type APIEmbed, + type APIUser, + ButtonStyle, + ComponentType +} from "@discordjs/core"; import { type RequestWithAttachments, RequestMessageRepository, @@ -8,7 +13,9 @@ import { import type { Application } from "../../../../Application.js"; import type { RequestsSettings } from "@prisma/client"; +import { DiscordAPIError } from "@discordjs/rest"; import { Module } from "@barry/core"; +import { getDWCEmbed } from "../../utils.js"; import { getRequestContent } from "./editor/functions/content.js"; import { loadEvents } from "../../../../utils/loadFolder.js"; @@ -55,6 +62,18 @@ export default class RequestsModule extends Module { this.requestsSettings = new RequestsSettingsRepository(client.prisma); } + /** + * Flags all requests for the specified user. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user to flag the requests of. + * @param reason The reason to flag the user. + */ + async flagUser(guildID: string, channelID: string, user: APIUser, reason: string): Promise { + return this.#resetRequests(guildID, channelID, user, 14, [getDWCEmbed(reason)]); + } + /** * Checks if the guild has enabled this module. * @@ -150,4 +169,52 @@ export default class RequestsModule extends Module { } } } + + /** + * Removes the flag from all requests for the specified user. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user to remove the flag of. + */ + async unflagUser(guildID: string, channelID: string, user: APIUser): Promise { + return this.#resetRequests(guildID, channelID, user, 21); + } + + /** + * Resets flagged requests for a user in a specific guild's channel. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user for whom the requests are being reset. + * @param maxDays The maximum number of days ago a request can be to be reset. + * @param embeds Optional array of embed objects to include in the updated messages. + */ + async #resetRequests( + guildID: string, + channelID: string, + user: APIUser, + maxDays: number = 14, + embeds: APIEmbed[] = [] + ): Promise { + const requests = await this.requests.getFlaggableByUser(guildID, user.id, maxDays); + for (const request of requests) { + const content = getRequestContent(user, request); + if (embeds.length > 0) { + content.embeds?.push(...embeds); + } + + for (const message of request.messages) { + try { + await this.client.api.channels.editMessage(channelID, message.messageID, content); + } catch (error: unknown) { + if (error instanceof DiscordAPIError && error.code === 10008) { + continue; + } + + this.client.logger.error(error); + } + } + } + } } diff --git a/apps/barry/src/modules/marketplace/utils.ts b/apps/barry/src/modules/marketplace/utils.ts index 4df202f..179e573 100644 --- a/apps/barry/src/modules/marketplace/utils.ts +++ b/apps/barry/src/modules/marketplace/utils.ts @@ -1,5 +1,11 @@ import type { ReplyableInteraction } from "@barry/core"; -import { ButtonStyle, ComponentType, MessageFlags } from "@discordjs/core"; +import { + type APIEmbed, + ButtonStyle, + ComponentType, + MessageFlags +} from "@discordjs/core"; +import config from "../../config.js"; /** * Represents a user that can be contacted. @@ -83,3 +89,19 @@ export async function displayContact(interaction: ReplyableInteraction, contacta flags: MessageFlags.Ephemeral }); } + +/** + * Returns the DWC embed for the user. + * + * @param reason The reason the user has been marked as DWC. + */ +export function getDWCEmbed(reason: string): APIEmbed { + return { + author: { + name: "Deal With Caution", + icon_url: config.emotes.error.imageURL + }, + description: "This user has been marked as `Deal With Caution`. If you have a business relationship with this person, proceed with caution.\n\n**Reason:**\n" + reason, + color: config.embedColor + }; +} diff --git a/apps/barry/src/modules/moderation/commands/chatinput/ban/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/ban/index.ts index a6e2619..5978960 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/ban/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/ban/index.ts @@ -213,12 +213,14 @@ export default class extends SlashCommand { }); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - duration: duration, - reason: options.reason, - user: options.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + duration: duration, + reason: options.reason, + user: options.user + }); + } } } diff --git a/apps/barry/src/modules/moderation/commands/chatinput/dwc/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/dwc/index.ts new file mode 100644 index 0000000..040a08f --- /dev/null +++ b/apps/barry/src/modules/moderation/commands/chatinput/dwc/index.ts @@ -0,0 +1,261 @@ +import { type APIUser, PermissionFlagsBits, MessageFlags, type APIRole, OverwriteType } from "@discordjs/core"; +import { + type ApplicationCommandInteraction, + SlashCommand, + SlashCommandOptionBuilder +} from "@barry/core"; +import { type ModerationSettings, CaseType } from "@prisma/client"; +import { type PartialGuildMember, isAboveMember } from "../../../functions/permissions.js"; +import type { + ProfilesModule, + RequestsModule, + SettingsWithChannel +} from "../../../types.js"; +import type ModerationModule from "../../../index.js"; + +import { COMMON_DWC_REASONS } from "../../../constants.js"; +import { DiscordAPIError } from "@discordjs/rest"; +import config from "../../../../../config.js"; + +/** + * Options for the deal with caution command. + */ +export interface DWCOptions { + /** + * The reason to flag the user. + */ + reason: string; + + /** + * The user to mark as 'Deal With Caution'. + */ + user: APIUser; +} + +/** + * Represents a slash command to mark a user as 'Deal with Caution'. + */ +export default class extends SlashCommand { + /** + * Represents a slash command to mark a user as 'Deal with Caution'. + * + * @param module The module this command belongs to. + */ + constructor(module: ModerationModule) { + super(module, { + name: "dwc", + description: "Marks a user as 'Deal With Caution' and bans them after a week.", + appPermissions: PermissionFlagsBits.BanMembers | PermissionFlagsBits.ManageRoles, + defaultMemberPermissions: PermissionFlagsBits.BanMembers, + guildOnly: true, + options: { + user: SlashCommandOptionBuilder.user({ + description: "The user to mark as 'Deal With Caution'.", + required: true + }), + reason: SlashCommandOptionBuilder.string({ + description: "The reason to flag the user (publicly visible).", + maximum: 200, + required: true, + autocomplete: (value) => COMMON_DWC_REASONS + .filter((x) => x.toLowerCase().startsWith(value.toLowerCase())) + .map((x) => ({ name: x, value: x })) + }) + } + }); + } + + /** + * Marks a user as 'Deal With Caution'. + * + * @param interaction The interaction that triggered the command. + * @param options The options for the command. + */ + async execute(interaction: ApplicationCommandInteraction, options: DWCOptions): Promise { + if (!interaction.isInvokedInGuild() || !interaction.data.isChatInput()) { + return; + } + + if (options.user.id === interaction.user.id) { + return interaction.createMessage({ + content: `${config.emotes.error} You cannot flag yourself.`, + flags: MessageFlags.Ephemeral + }); + } + + if (options.user.id === this.client.applicationID) { + return interaction.createMessage({ + content: `${config.emotes.error} Your attempt to flag me has been classified as a failed comedy show audition.`, + flags: MessageFlags.Ephemeral + }); + } + + const guild = await this.client.api.guilds.get(interaction.guildID); + const member = interaction.data.resolved.members.get(options.user.id); + if (member !== undefined) { + if (!isAboveMember(guild, interaction.member, member)) { + return interaction.createMessage({ + content: `${config.emotes.error} You cannot flag this member.`, + flags: MessageFlags.Ephemeral + }); + } + + const self = await this.client.api.guilds.getMember(interaction.guildID, this.client.applicationID); + if (!isAboveMember(guild, self as PartialGuildMember, member)) { + return interaction.createMessage({ + content: `${config.emotes.error} I cannot flag this member.`, + flags: MessageFlags.Ephemeral + }); + } + } + + const marketplace = this.client.modules.get("marketplace"); + const profiles = marketplace?.dependencies.get("profiles") as ProfilesModule; + const requests = marketplace?.dependencies.get("requests") as RequestsModule; + + const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); + const profilesSettings = await profiles?.profilesSettings.getOrCreate(interaction.guildID); + const requestsSettings = await requests?.requestsSettings.getOrCreate(interaction.guildID); + + const role = await this.getOrCreateRole(settings, profilesSettings, requestsSettings); + if (role === undefined) { + return interaction.createMessage({ + content: `${config.emotes.error} Failed to create the DWC role.`, + flags: MessageFlags.Ephemeral + }); + } + + if (member !== undefined) { + try { + await this.client.api.guilds.addRoleToMember(interaction.guildID, options.user.id, role.id, { + reason: options.reason + }); + } catch (error: unknown) { + return interaction.createMessage({ + content: `${config.emotes.error} Failed to add the DWC role to the member.`, + flags: MessageFlags.Ephemeral + }); + } + } + + try { + const guild = await this.client.api.guilds.get(interaction.guildID); + const channel = await this.client.api.users.createDM(options.user.id); + + let content = `You have been marked with \`Deal With Caution\` in **${guild.name}**\n\n`; + if (profilesSettings?.enabled || requestsSettings?.enabled) { + content += "Meaning you cannot:\n- look at requests\n- create nor modify requests\n- advertise your services\n\n"; + } + + await this.client.api.channels.createMessage(channel.id, { + embeds: [{ + color: config.defaultColor, + description: `${config.emotes.error} ${content}In order to get this removed, please contact one of the staff members.\n**If you are still marked as Deal With Caution a week from now, you will automatically be banned.**`, + fields: [{ + name: "**Reason**", + value: options.reason + }] + }] + }); + } catch (error: unknown) { + if (!(error instanceof DiscordAPIError) || error.code !== 50007) { + this.client.logger.error(error); + } + } + + await this.module.dwcScheduledBans.create(interaction.guildID, options.user.id); + + const entity = await this.module.cases.create({ + creatorID: interaction.user.id, + guildID: interaction.guildID, + note: options.reason, + type: CaseType.DWC, + userID: options.user.id + }); + + await interaction.createMessage({ + content: `${config.emotes.check} Case \`${entity.id}\` | Successfully flagged \`${options.user.username}\`.`, + flags: MessageFlags.Ephemeral + }); + + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.reason, + user: options.user + }); + } + + if (profilesSettings !== undefined && profilesSettings.channelID !== null) { + await profiles?.flagUser( + interaction.guildID, + profilesSettings.channelID, + options.user, + options.reason + ); + } + + if (requestsSettings !== undefined && requestsSettings.channelID !== null) { + await requests?.flagUser( + interaction.guildID, + requestsSettings.channelID, + options.user, + options.reason + ); + } + } + + /** + * Retrieves or creates the 'Deal With Caution' role. + * + * @param settings The moderation settings for the guild. + * @param profilesSettings The profiles settings for the guild. + * @param requestsSettings The requests settings for the guild. + * @returns The 'Deal With Caution' role, or undefined if it failed to create it. + */ + async getOrCreateRole( + settings: ModerationSettings, + profilesSettings?: SettingsWithChannel, + requestsSettings?: SettingsWithChannel + ): Promise { + if (settings.dwcRoleID !== undefined) { + const roles = await this.client.api.guilds.getRoles(settings.guildID); + const role = roles.find((r) => r.id === settings.dwcRoleID); + + if (role !== undefined) { + return role; + } + } + + try { + const role = await this.client.api.guilds.createRole(settings.guildID, { + color: config.defaultDWCColor, + hoist: true, + name: "Deal With Caution" + }); + + await this.module.moderationSettings.upsert(settings.guildID, { + dwcRoleID: role.id + }); + + if (profilesSettings !== undefined && profilesSettings.channelID !== null) { + await this.client.api.channels.editPermissionOverwrite(profilesSettings.channelID, role.id, { + deny: PermissionFlagsBits.ViewChannel.toString(), + type: OverwriteType.Role + }); + } + + if (requestsSettings !== undefined && requestsSettings.channelID !== null) { + await this.client.api.channels.editPermissionOverwrite(requestsSettings.channelID, role.id, { + deny: PermissionFlagsBits.ViewChannel.toString(), + type: OverwriteType.Role + }); + } + + return role; + } catch (error: unknown) { + this.client.logger.error(error); + } + } +} diff --git a/apps/barry/src/modules/moderation/commands/chatinput/kick/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/kick/index.ts index c81916f..e261895 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/kick/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/kick/index.ts @@ -135,11 +135,13 @@ export default class extends SlashCommand { }); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - reason: options.reason, - user: options.member.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.reason, + user: options.member.user + }); + } } } diff --git a/apps/barry/src/modules/moderation/commands/chatinput/mute/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/mute/index.ts index c4f223d..f9035f9 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/mute/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/mute/index.ts @@ -166,12 +166,14 @@ export default class extends SlashCommand { }); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - duration: duration, - reason: options.reason, - user: options.member.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + duration: duration, + reason: options.reason, + user: options.member.user + }); + } } } diff --git a/apps/barry/src/modules/moderation/commands/chatinput/note/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/note/index.ts index 695c396..c56a1a0 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/note/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/note/index.ts @@ -82,11 +82,13 @@ export default class extends SlashCommand { }); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - reason: options.note, - user: options.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.note, + user: options.user + }); + } } } diff --git a/apps/barry/src/modules/moderation/commands/chatinput/unban/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/unban/index.ts index 8ceed5c..4da9c01 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/unban/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/unban/index.ts @@ -103,11 +103,13 @@ export default class extends SlashCommand { }); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - reason: options.reason, - user: options.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.reason, + user: options.user + }); + } } } diff --git a/apps/barry/src/modules/moderation/commands/chatinput/unmute/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/unmute/index.ts index 1af4ba0..554dc26 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/unmute/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/unmute/index.ts @@ -107,11 +107,13 @@ export default class extends SlashCommand { }); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - reason: options.reason, - user: options.member.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.reason, + user: options.member.user + }); + } } } diff --git a/apps/barry/src/modules/moderation/commands/chatinput/warn/index.ts b/apps/barry/src/modules/moderation/commands/chatinput/warn/index.ts index 00a4ce7..321f294 100644 --- a/apps/barry/src/modules/moderation/commands/chatinput/warn/index.ts +++ b/apps/barry/src/modules/moderation/commands/chatinput/warn/index.ts @@ -131,12 +131,14 @@ export default class extends SlashCommand { await this.#onSuccess(interaction, entity.id, options.member.user); const settings = await this.module.moderationSettings.getOrCreate(interaction.guildID); - await this.module.createLogMessage({ - case: entity, - creator: interaction.user, - reason: options.reason, - user: options.member.user - }, settings); + if (settings.channelID !== null) { + await this.module.createLogMessage(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.reason, + user: options.member.user + }); + } } /** diff --git a/apps/barry/src/modules/moderation/constants.ts b/apps/barry/src/modules/moderation/constants.ts index b64d204..da91e6d 100644 --- a/apps/barry/src/modules/moderation/constants.ts +++ b/apps/barry/src/modules/moderation/constants.ts @@ -6,9 +6,11 @@ import config, { type Emoji } from "../../config.js"; */ export const CASE_EMOJIS: Record = { [CaseType.Ban]: config.emotes.ban, + [CaseType.DWC]: config.emotes.dwc, [CaseType.Kick]: config.emotes.kick, [CaseType.Mute]: config.emotes.mute, [CaseType.Note]: config.emotes.note, + [CaseType.UnDWC]: config.emotes.undwc, [CaseType.Unban]: config.emotes.unban, [CaseType.Unmute]: config.emotes.unmute, [CaseType.Warn]: config.emotes.warn @@ -19,9 +21,11 @@ export const CASE_EMOJIS: Record = { */ export const CASE_TITLES: Record = { [CaseType.Ban]: "Ban", + [CaseType.DWC]: "DWC", [CaseType.Kick]: "Kick", [CaseType.Mute]: "Mute", [CaseType.Note]: "Note", + [CaseType.UnDWC]: "UnDWC", [CaseType.Unban]: "Unban", [CaseType.Unmute]: "Unmute", [CaseType.Warn]: "Warn" diff --git a/apps/barry/src/modules/moderation/database.ts b/apps/barry/src/modules/moderation/database.ts index e88d5fd..f331e39 100644 --- a/apps/barry/src/modules/moderation/database.ts +++ b/apps/barry/src/modules/moderation/database.ts @@ -2,6 +2,7 @@ import type { Case, CaseNote, CaseType, + DWCScheduledBan, ModerationSettings, Prisma, PrismaClient, @@ -73,6 +74,36 @@ export interface CreateCaseNoteOptions { guildID: string; } +/** + * Represents an expired scheduled ban. + */ +export interface ExpiredDWCScheduledBan { + /** + * The channel in which to log the case. + */ + channel_id: string | null; + + /** + * When the user got flagged. + */ + created_at: Date; + + /** + * The ID of the DWC role. + */ + dwc_role_id: string | null; + + /** + * The ID of the guild. + */ + guild_id: string; + + /** + * The ID of the user. + */ + user_id: string; +} + /** * Repository class for managing moderation cases. */ @@ -389,6 +420,83 @@ export class ModerationSettingsRepository { } } +/** + * Repository class for managing scheduled bans. + */ +export class DWCScheduledBanRepository { + /** + * The Prisma client used to interact with the database. + */ + #prisma: PrismaClient; + + /** + * Repository class for managing scheduled bans. + * + * @param prisma The Prisma client used to interact with the database. + */ + constructor(prisma: PrismaClient) { + this.#prisma = prisma; + } + + /** + * Creates a new scheduled ban for the specified user. + * + * @param guildID The ID of the guild. + * @param userID The ID of the user. + * @returns The new scheduled ban record. + */ + async create(guildID: string, userID: string): Promise { + return this.#prisma.dWCScheduledBan.create({ + data: { guildID, userID } + }); + } + + /** + * Deletes a scheduled ban. + * + * @param guildID The ID of the guild. + * @param userID The ID of the user. + * @returns The deleted ban. + */ + async delete(guildID: string, userID: string): Promise { + return this.#prisma.dWCScheduledBan.delete({ + where: { + guildID_userID: { guildID, userID } + } + }); + } + + /** + * Retrieves the scheduled ban for a user. + * + * @param guildID The ID of the guild. + * @param userID The ID of the user. + * @returns The scheduled ban, or null if not found. + */ + async get(guildID: string, userID: string): Promise { + return this.#prisma.dWCScheduledBan.findUnique({ + where: { + guildID_userID: { guildID, userID } + } + }); + } + + /** + * Retrieves all scheduled bans that are due for executing. + * + * @returns An array of scheduled bans. + */ + async getExpired(): Promise { + return this.#prisma.$queryRaw` + SELECT d.*, s.channel_id, s.dwc_role_id + FROM dwc_scheduled_bans AS d + INNER JOIN moderation_settings AS s + ON s.guild_id = d.guild_id + WHERE d.created_at <= NOW() + (s.dwc_days * INTERVAL '1 day'); + `; + } +} + /** * Repository class for managing temporary bans. */ diff --git a/apps/barry/src/modules/moderation/index.ts b/apps/barry/src/modules/moderation/index.ts index a6ecae7..5ce390a 100644 --- a/apps/barry/src/modules/moderation/index.ts +++ b/apps/barry/src/modules/moderation/index.ts @@ -1,14 +1,17 @@ +import type { APIGuild, APIUser } from "@discordjs/core"; import { type CaseLogOptions, getLogContent } from "./functions/getLogContent.js"; -import { type ModerationSettings, CaseType } from "@prisma/client"; -import type { APIGuild } from "@discordjs/core"; -import type { Application } from "../../Application.js"; - import { + type ExpiredDWCScheduledBan, CaseNoteRepository, CaseRepository, + DWCScheduledBanRepository, ModerationSettingsRepository, TempBanRepository } from "./database.js"; +import type { ProfilesModule, RequestsModule } from "./types.js"; +import type { Application } from "../../Application.js"; + +import { CaseType } from "@prisma/client"; import { DiscordAPIError } from "@discordjs/rest"; import { Module } from "@barry/core"; import { loadCommands } from "../../utils/loadFolder.js"; @@ -36,7 +39,7 @@ export interface NotifyOptions { /** * The type of action taken. */ - type: Exclude; + type: Exclude; /** * The ID of the user to notify. @@ -47,7 +50,7 @@ export interface NotifyOptions { /** * The words to use for each case type. */ -const NOTIFY_WORDS: Record, string> = { +const NOTIFY_WORDS: Record, string> = { [CaseType.Ban]: "banned from", [CaseType.Kick]: "kicked from", [CaseType.Mute]: "muted in", @@ -56,11 +59,26 @@ const NOTIFY_WORDS: Record, string> = { [CaseType.Warn]: "warned in" }; +/** + * How often to check for expired scheduled bans. + */ +const DWC_BAN_INTERVAL = 600000; + +/** + * The reason to display for expired bans. + */ +const DWC_BAN_REASON = "User did not resolve issue."; + /** * How often to check for expired temporary bans. */ const UNBAN_INTERVAL = 600000; +/** + * The reason to display when a user no longer has the DWC role. + */ +const UNKNOWN_UNDWC_REASON = "The DWC role was removed manually. The user will not be banned."; + /** * Represents the moderation module. */ @@ -75,6 +93,11 @@ export default class ModerationModule extends Module { */ cases: CaseRepository; + /** + * Repository class for managing scheduled bans. + */ + dwcScheduledBans: DWCScheduledBanRepository; + /** * Repository class for managing settings for this module. */ @@ -100,6 +123,7 @@ export default class ModerationModule extends Module { this.caseNotes = new CaseNoteRepository(client.prisma); this.cases = new CaseRepository(client.prisma); + this.dwcScheduledBans = new DWCScheduledBanRepository(client.prisma); this.moderationSettings = new ModerationSettingsRepository(client.prisma); this.tempBans = new TempBanRepository(client.prisma); } @@ -133,37 +157,72 @@ export default class ModerationModule extends Module { }); const settings = await this.moderationSettings.getOrCreate(ban.guildID); - const user = await this.client.api.users.get(ban.userID); - - await this.createLogMessage({ - case: entity, - creator: self, - reason: "Temporary ban expired.", - user: user - }, settings); + if (settings.channelID !== null) { + const user = await this.client.api.users.get(ban.userID); + await this.createLogMessage(settings.channelID, { + case: entity, + creator: self, + reason: "Temporary ban expired.", + user: user + }); + } } } /** - * Creates a log message in the configured log channel. - * - * @param options The options for the log message - * @param settings The settings of this module. + * Checks for expired scheduled bans and bans the users. */ - async createLogMessage(options: CaseLogOptions, settings: ModerationSettings): Promise { - if (settings.channelID !== null) { + async checkScheduledBans(): Promise { + const bans = await this.dwcScheduledBans.getExpired(); + if (bans.length === 0) { + return; + } + + const creator = await this.client.api.users.get(this.client.applicationID); + for (const ban of bans) { try { - const content = getLogContent(options); - await this.client.api.channels.createMessage(settings.channelID, content); + const member = await this.client.api.guilds.getMember(ban.guild_id, ban.user_id); + if (member.user === undefined) { + throw new Error("Missing required property 'user' on member."); + } + + if (ban.dwc_role_id === null || !member.roles.includes(ban.dwc_role_id)) { + await this.unflagUser(creator, member.user, ban); + } else { + await this.punishFlaggedUser(creator, member.user, ban); + } } catch (error: unknown) { - if (error instanceof DiscordAPIError && error.code === 10003) { - await this.moderationSettings.upsert(settings.guildID, { - channelID: null - }); + if (error instanceof DiscordAPIError && error.code === 10007) { + const user = await this.client.api.users.get(ban.user_id); + + await this.punishFlaggedUser(creator, user, ban); + } else { + this.client.logger.error(error); } + } - this.client.logger.error(error); + await this.dwcScheduledBans.delete(ban.guild_id, ban.user_id); + } + } + + /** + * Creates a log message in the configured log channel. + * + * @param channelID The ID of the log channel. + * @param options The options for the log message. + */ + async createLogMessage(channelID: string, options: CaseLogOptions): Promise { + try { + const content = getLogContent(options); + await this.client.api.channels.createMessage(channelID, content); + } catch (error: unknown) { + if (error instanceof DiscordAPIError && error.code === 10003) { + await this.moderationSettings.upsert(options.case.guildID, { + channelID: null + }); } + + this.client.logger.error(error); } } @@ -176,6 +235,10 @@ export default class ModerationModule extends Module { setInterval(() => { return this.checkExpiredBans(); }, UNBAN_INTERVAL); + + setInterval(() => { + return this.checkScheduledBans(); + }, DWC_BAN_INTERVAL); } /** @@ -235,4 +298,86 @@ export default class ModerationModule extends Module { } } } + + /** + * Bans a flagged user. + * + * @param self The client user. + * @param user The flagged user to ban. + * @param ban The scheduled ban. + */ + async punishFlaggedUser(self: APIUser, user: APIUser, ban: ExpiredDWCScheduledBan): Promise { + await this.client.api.guilds.banUser(ban.guild_id, ban.user_id, {}, { + reason: DWC_BAN_REASON + }); + + const entity = await this.cases.create({ + creatorID: this.client.applicationID, + guildID: ban.guild_id, + note: DWC_BAN_REASON, + type: CaseType.Ban, + userID: ban.user_id + }); + + const guild = await this.client.api.guilds.get(ban.guild_id); + await this.notifyUser({ + guild: guild, + reason: DWC_BAN_REASON, + type: CaseType.Ban, + userID: ban.user_id + }); + + if (ban.channel_id !== null) { + await this.createLogMessage(ban.channel_id, { + case: entity, + creator: self, + reason: DWC_BAN_REASON, + user: user + }); + } + } + + /** + * Removes the flag from the user. + * + * @param self The client user. + * @param user The user to remove the flag of. + * @param ban The scheduled ban. + */ + async unflagUser(self: APIUser, user: APIUser, ban: ExpiredDWCScheduledBan): Promise { + const entity = await this.cases.create({ + creatorID: this.client.applicationID, + guildID: ban.guild_id, + note: UNKNOWN_UNDWC_REASON, + type: CaseType.UnDWC, + userID: user.id + }); + + const marketplace = this.client.modules.get("marketplace"); + const profiles = marketplace?.dependencies.get("profiles") as ProfilesModule; + const requests = marketplace?.dependencies.get("requests") as RequestsModule; + + if (profiles !== undefined) { + const profilesSettings = await profiles.profilesSettings.getOrCreate(ban.guild_id); + if (profilesSettings.channelID !== null) { + await profiles.unflagUser(ban.guild_id, profilesSettings.channelID, user); + } + } + + if (requests !== undefined) { + const requestsSettings = await requests.requestsSettings.getOrCreate(ban.guild_id); + if (requestsSettings.channelID !== null) { + await requests.unflagUser(ban.guild_id, requestsSettings.channelID, user); + } + } + + if (ban.channel_id !== null) { + await this.createLogMessage(ban.channel_id, { + case: entity, + creator: self, + reason: UNKNOWN_UNDWC_REASON, + user: user + }); + } + } } diff --git a/apps/barry/src/modules/moderation/types.ts b/apps/barry/src/modules/moderation/types.ts new file mode 100644 index 0000000..6e6851f --- /dev/null +++ b/apps/barry/src/modules/moderation/types.ts @@ -0,0 +1,74 @@ +import type { APIUser } from "@discordjs/core"; +import type { Module } from "@barry/core"; + +/** + * Represents a module that allows flagging users. + */ +export interface FlaggableModule extends Module { + /** + * Flags all items for the specified user. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user to flag. + * @param reason The reason to flag the user. + */ + flagUser(guildID: string, channelID: string, user: APIUser, reason: string): Promise; + + /** + * Removes the flag from all items for the specified user. + * + * @param guildID The ID of the guild. + * @param channelID The ID of the channel. + * @param user The user to remove the flag of. + */ + unflagUser(guildID: string, channelID: string, user: APIUser): Promise; +} + +/** + * Represents the profiles module. + */ +export interface ProfilesModule extends FlaggableModule { + /** + * The settings repository for the profiles module. + */ + profilesSettings: SettingsRepository; +} + +/** + * Represents the requests module. + */ +export interface RequestsModule extends FlaggableModule { + /** + * The settings repository for the requests module. + */ + requestsSettings: SettingsRepository; +} + +/** + * Represents a simple settings repository to fetch a configured channel. + */ +export interface SettingsRepository { + /** + * Get the settings of the specified guild. + * + * @param guildID The ID of the guild. + * @returns The settings for the specified guild. + */ + getOrCreate(guildID: string): Promise; +} + +/** + * Represents settings of a module with a configured channel. + */ +export interface SettingsWithChannel { + /** + * The ID of the channel. + */ + channelID: string | null; + + /** + * Whether the module is enabled. + */ + enabled: boolean; +} diff --git a/apps/barry/tests/modules/marketplace/profiles/database.test.ts b/apps/barry/tests/modules/marketplace/profiles/database.test.ts index 0221f94..3411aaa 100644 --- a/apps/barry/tests/modules/marketplace/profiles/database.test.ts +++ b/apps/barry/tests/modules/marketplace/profiles/database.test.ts @@ -176,6 +176,42 @@ describe("ProfileRepository", () => { }); }); + describe("getWithFlaggableMessages", () => { + it("should retrieve the profile record for the specified user", async () => { + vi.useFakeTimers().setSystemTime("01-01-2023"); + vi.mocked(prisma.profile.findUnique).mockResolvedValue(mockProfile); + + const timestamp = BigInt(Date.now() - 1421280000000); + const minimumID = String(timestamp << 22n); + + const entity = await repository.getWithFlaggableMessages(guildID, userID); + + expect(entity).toEqual(mockProfile); + expect(prisma.profile.findUnique).toHaveBeenCalledOnce(); + expect(prisma.profile.findUnique).toHaveBeenCalledWith({ + include: { + messages: { + where: { + guildID: guildID, + messageID: { + gte: minimumID + } + } + } + }, + where: { userID } + }); + }); + + it("should return null when no profile record is found", async () => { + vi.mocked(prisma.profile.findUnique).mockResolvedValue(null); + + const entity = await repository.getWithFlaggableMessages(guildID, userID); + + expect(entity).toBeNull(); + }); + }); + describe("getWithMessages", () => { it("should retrieve the profile record for the specified user", async () => { vi.mocked(prisma.profile.findUnique).mockResolvedValue(mockProfile); diff --git a/apps/barry/tests/modules/marketplace/profiles/index.test.ts b/apps/barry/tests/modules/marketplace/profiles/index.test.ts index c6accdb..77554b1 100644 --- a/apps/barry/tests/modules/marketplace/profiles/index.test.ts +++ b/apps/barry/tests/modules/marketplace/profiles/index.test.ts @@ -7,6 +7,8 @@ import { ProfilesSettingsRepository } from "../../../../src/modules/marketplace/dependencies/profiles/database.js"; import { mockUser, mockMessage } from "@barry/testing"; + +import { DiscordAPIError } from "@discordjs/rest"; import { createMockApplication } from "../../../mocks/application.js"; import { getProfileContent } from "../../../../src/modules/marketplace/dependencies/profiles/editor/functions/content.js"; import { mockProfile } from "./mocks/profile.js"; @@ -17,6 +19,9 @@ import ProfilesModule, { } from "../../../../src/modules/marketplace/dependencies/profiles/index.js"; describe("ProfilesModule", () => { + const channelID = "48527482987641760"; + const guildID = "68239102456844360"; + let module: ProfilesModule; let settings: ProfilesSettings; @@ -42,6 +47,27 @@ describe("ProfilesModule", () => { }); }); + describe("flagUser", () => { + beforeEach(() => { + vi.spyOn(module.client.api.channels, "editMessage").mockResolvedValue(mockMessage); + vi.spyOn(module.profiles, "getWithFlaggableMessages").mockResolvedValueOnce({ + ...mockProfile, + messages: [{ + guildID: guildID, + messageID: "30527482987641765", + userID: mockUser.id + }] + }); + }); + + it("should flag all requests that are newer than 14 days", async () => { + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.profiles.getWithFlaggableMessages).toHaveBeenCalledOnce(); + expect(module.profiles.getWithFlaggableMessages).toHaveBeenCalledWith(guildID, mockUser.id, 14); + }); + }); + describe("isEnabled", () => { it("should return true if the guild has the module enabled", async () => { const enabled = await module.isEnabled("68239102456844360"); @@ -156,4 +182,84 @@ describe("ProfilesModule", () => { expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining("Could not delete last message")); }); }); + + describe("unflagUser", () => { + beforeEach(() => { + vi.spyOn(module.client.api.channels, "editMessage").mockResolvedValue(mockMessage); + vi.spyOn(module.profiles, "getWithFlaggableMessages").mockResolvedValueOnce({ + ...mockProfile, + messages: [{ + guildID: guildID, + messageID: "30527482987641765", + userID: mockUser.id + }] + }); + }); + + it("should unflag all requests that are newer than 21 days", async () => { + await module.unflagUser(guildID, channelID, mockUser); + + expect(module.profiles.getWithFlaggableMessages).toHaveBeenCalledOnce(); + expect(module.profiles.getWithFlaggableMessages).toHaveBeenCalledWith(guildID, mockUser.id, 21); + }); + }); + + describe("#resetProfiles", () => { + beforeEach(() => { + vi.spyOn(module.client.api.channels, "editMessage").mockResolvedValue(mockMessage); + vi.spyOn(module.profiles, "getWithFlaggableMessages").mockResolvedValueOnce({ + ...mockProfile, + messages: [{ + guildID: guildID, + messageID: "30527482987641765", + userID: mockUser.id + }] + }); + }); + + it("should get all flaggable messages within the specified days", async () => { + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.profiles.getWithFlaggableMessages).toHaveBeenCalledOnce(); + expect(module.profiles.getWithFlaggableMessages).toHaveBeenCalledWith(guildID, mockUser.id, 14); + }); + + it("should append the provided embeds to the profile content", async () => { + const editSpy = vi.spyOn(module.client.api.channels, "editMessage"); + + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(editSpy).toHaveBeenCalledOnce(); + expect(editSpy).toHaveBeenCalledWith("48527482987641760", "30527482987641765", { + content: expect.any(String), + embeds: [ + expect.any(Object), + expect.any(Object) + ] + }); + }); + + it("should log an error if the message could not be edited", async () => { + const error = new Error("Oh no!"); + vi.spyOn(module.client.api.channels, "editMessage").mockRejectedValue(error); + + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.client.logger.error).toHaveBeenCalledOnce(); + expect(module.client.logger.error).toHaveBeenCalledWith(error); + }); + + it("should not log an error if the message could not be edited because it was deleted", async () => { + const response = { + code: 10008, + message: "Unknown message" + }; + const error = new DiscordAPIError(response, 10008, 404, "DELETE", "", {}); + vi.spyOn(module.client.api.channels, "editMessage").mockRejectedValue(error); + + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.client.logger.error).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/barry/tests/modules/marketplace/requests/database.test.ts b/apps/barry/tests/modules/marketplace/requests/database.test.ts index cbc183d..6d6a3ba 100644 --- a/apps/barry/tests/modules/marketplace/requests/database.test.ts +++ b/apps/barry/tests/modules/marketplace/requests/database.test.ts @@ -10,6 +10,7 @@ import { mockRequest } from "./mocks/request.js"; import { prisma } from "../../../mocks/index.js"; describe("RequestRepository", () => { + const guildID = "68239102456844360"; const userID = "257522665441460225"; const requestID = 1; @@ -147,6 +148,53 @@ describe("RequestRepository", () => { }); }); + describe("getFlaggableByUser", () => { + it("should return the flaggable requests for the specified user", async () => { + vi.useFakeTimers().setSystemTime("01-01-2023"); + vi.mocked(prisma.request.findMany).mockResolvedValue([mockRequest]); + + const timestamp = BigInt(Date.now() - 1421280000000); + const minimumID = String(timestamp << 22n); + + const entities = await repository.getFlaggableByUser("68239102456844360", userID); + + expect(entities).toEqual([mockRequest]); + expect(prisma.request.findMany).toHaveBeenCalledOnce(); + expect(prisma.request.findMany).toHaveBeenCalledWith({ + include: { + attachments: true, + messages: { + where: { + guildID: guildID, + messageID: { + gte: minimumID + } + } + } + }, + where: { + messages: { + some: { + guildID: guildID, + messageID: { + gte: minimumID + } + } + }, + userID: userID + } + }); + }); + + it("should return an empty array if the user has no flaggable requests", async () => { + vi.mocked(prisma.request.findMany).mockResolvedValue([]); + + const entities = await repository.getFlaggableByUser("68239102456844360", userID, 7); + + expect(entities).toEqual([]); + }); + }); + describe("upsert", () => { it("should update an existing draft if one exists", async () => { vi.mocked(prisma.request.findFirst).mockResolvedValue(mockRequest); diff --git a/apps/barry/tests/modules/marketplace/requests/index.test.ts b/apps/barry/tests/modules/marketplace/requests/index.test.ts index d78e100..ade39cc 100644 --- a/apps/barry/tests/modules/marketplace/requests/index.test.ts +++ b/apps/barry/tests/modules/marketplace/requests/index.test.ts @@ -7,6 +7,8 @@ import { RequestsSettingsRepository } from "../../../../src/modules/marketplace/dependencies/requests/database.js"; import { mockUser, mockMessage } from "@barry/testing"; + +import { DiscordAPIError } from "@discordjs/rest"; import { createMockApplication } from "../../../mocks/application.js"; import { getRequestContent } from "../../../../src/modules/marketplace/dependencies/requests/editor/functions/content.js"; import { mockRequest } from "./mocks/request.js"; @@ -17,6 +19,9 @@ import RequestsModule, { } from "../../../../src/modules/marketplace/dependencies/requests/index.js"; describe("RequestsModule", () => { + const channelID = "48527482987641760"; + const guildID = "68239102456844360"; + let module: RequestsModule; let settings: RequestsSettings; @@ -41,6 +46,27 @@ describe("RequestsModule", () => { }); }); + describe("flagUser", () => { + beforeEach(() => { + vi.spyOn(module.client.api.channels, "editMessage").mockResolvedValue(mockMessage); + vi.spyOn(module.requests, "getFlaggableByUser").mockResolvedValueOnce([{ + ...mockRequest, + messages: [{ + guildID: guildID, + messageID: "30527482987641765", + requestID: 1 + }] + }]); + }); + + it("should flag all requests that are newer than 14 days", async () => { + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.requests.getFlaggableByUser).toHaveBeenCalledOnce(); + expect(module.requests.getFlaggableByUser).toHaveBeenCalledWith(guildID, mockUser.id, 14); + }); + }); + describe("isEnabled", () => { it("should return true if the guild has the module enabled", async () => { const settingsSpy = vi.spyOn(module.requestsSettings, "getOrCreate").mockResolvedValue(settings); @@ -180,4 +206,84 @@ describe("RequestsModule", () => { expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining("Could not delete last message")); }); }); + + describe("unflagUser", () => { + beforeEach(() => { + vi.spyOn(module.client.api.channels, "editMessage").mockResolvedValue(mockMessage); + vi.spyOn(module.requests, "getFlaggableByUser").mockResolvedValueOnce([{ + ...mockRequest, + messages: [{ + guildID: guildID, + messageID: "30527482987641765", + requestID: 1 + }] + }]); + }); + + it("should flag all requests that are newer than 21 days", async () => { + await module.unflagUser(guildID, channelID, mockUser); + + expect(module.requests.getFlaggableByUser).toHaveBeenCalledOnce(); + expect(module.requests.getFlaggableByUser).toHaveBeenCalledWith(guildID, mockUser.id, 21); + }); + }); + + describe("#resetRequests", () => { + beforeEach(() => { + vi.spyOn(module.client.api.channels, "editMessage").mockResolvedValue(mockMessage); + vi.spyOn(module.requests, "getFlaggableByUser").mockResolvedValueOnce([{ + ...mockRequest, + messages: [{ + guildID: guildID, + messageID: mockMessage.id, + requestID: 1 + }] + }]); + }); + + it("should get all flaggable requests within the specified days", async () => { + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.requests.getFlaggableByUser).toHaveBeenCalledOnce(); + expect(module.requests.getFlaggableByUser).toHaveBeenCalledWith(guildID, mockUser.id, 14); + }); + + it("should append the provided embeds to the request content", async () => { + const editSpy = vi.spyOn(module.client.api.channels, "editMessage"); + + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(editSpy).toHaveBeenCalledOnce(); + expect(editSpy).toHaveBeenCalledWith(channelID, mockMessage.id, { + content: expect.any(String), + embeds: [ + expect.any(Object), + expect.any(Object) + ] + }); + }); + + it("should log an error if the message could not be edited", async () => { + const error = new Error("Oh no!"); + vi.spyOn(module.client.api.channels, "editMessage").mockRejectedValue(error); + + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.client.logger.error).toHaveBeenCalledOnce(); + expect(module.client.logger.error).toHaveBeenCalledWith(error); + }); + + it("should not log an error if the message could not be edited because it was deleted", async () => { + const response = { + code: 10008, + message: "Unknown message" + }; + const error = new DiscordAPIError(response, 10008, 404, "DELETE", "", {}); + vi.spyOn(module.client.api.channels, "editMessage").mockRejectedValue(error); + + await module.flagUser(guildID, channelID, mockUser, "Hello World!"); + + expect(module.client.logger.error).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/ban/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/ban/index.test.ts index 12be667..22cd209 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/ban/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/ban/index.test.ts @@ -344,12 +344,22 @@ describe("/ban", () => { await command.execute(interaction, options); expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy).toHaveBeenCalledWith({ + expect(createSpy).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, reason: options.reason, user: options.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + vi.spyOn(permissions, "isAboveMember").mockReturnValue(true); + const createSpy = vi.spyOn(command.module, "createLogMessage"); + settings.channelID = null; + + await command.execute(interaction, options); + + expect(createSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts new file mode 100644 index 0000000..3d9b5de --- /dev/null +++ b/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts @@ -0,0 +1,514 @@ +import { + type Case, + type ModerationSettings, + type ProfilesSettings, + type RequestsSettings, + CaseType +} from "@prisma/client"; + +import { + ApplicationCommandType, + MessageFlags, + OverwriteType +} from "@discordjs/core"; +import { + createMockApplicationCommandInteraction, + mockChannel, + mockGuild, + mockMember, + mockMessage, + mockRole, + mockUser +} from "@barry/testing"; + +import { ApplicationCommandInteraction } from "@barry/core"; +import { DiscordAPIError } from "@discordjs/rest"; +import { createMockApplication } from "../../../../../mocks/application.js"; +import { mockCase } from "../../../mocks/case.js"; + +import DWCCommand, { type DWCOptions } from "../../../../../../src/modules/moderation/commands/chatinput/dwc/index.js"; +import MarketplaceModule from "../../../../../../src/modules/marketplace/index.js"; +import ModerationModule from "../../../../../../src/modules/moderation/index.js"; +import ProfilesModule from "../../../../../../src/modules/marketplace/dependencies/profiles/index.js"; +import RequestsModule from "../../../../../../src/modules/marketplace/dependencies/requests/index.js"; +import * as permissions from "../../../../../../src/modules/moderation/functions/permissions.js"; + +describe("/dwc", () => { + let command: DWCCommand; + let entity: Case; + let interaction: ApplicationCommandInteraction; + let options: DWCOptions; + + let marketplaceModule: MarketplaceModule; + let profilesModule: ProfilesModule; + let requestsModule: RequestsModule; + + let profilesSettings: ProfilesSettings; + let requestsSettings: RequestsSettings; + let settings: ModerationSettings; + + beforeEach(async () => { + const client = createMockApplication(); + const data = createMockApplicationCommandInteraction(); + interaction = new ApplicationCommandInteraction(data, client, vi.fn()); + + marketplaceModule = new MarketplaceModule(client); + profilesModule = new ProfilesModule(client); + requestsModule = new RequestsModule(client); + + await client.modules.add(marketplaceModule); + await marketplaceModule.dependencies.add(profilesModule); + await marketplaceModule.dependencies.add(requestsModule); + + const module = new ModerationModule(client); + command = new DWCCommand(module); + + entity = { ...mockCase, type: CaseType.DWC }; + options = { + reason: "Hello World!", + user: { ...mockUser, id: "257522665437265920" } + }; + profilesSettings = { + channelID: mockChannel.id, + enabled: true, + guildID: mockGuild.id, + lastMessageID: null + }; + requestsSettings = { + channelID: mockChannel.id, + enabled: true, + guildID: mockGuild.id, + lastMessageID: null, + minCompensation: 50 + }; + settings = { + channelID: mockChannel.id, + dwcDays: 7, + dwcRoleID: mockRole.id, + enabled: true, + guildID: mockGuild.id + }; + + if (interaction.data.isChatInput()) { + interaction.data.resolved.members.set(options.user.id, { + ...mockMember, + permissions: "0", + user: options.user + }); + } + + profilesModule.flagUser = vi.fn(); + requestsModule.flagUser = vi.fn(); + vi.spyOn(module.moderationSettings, "getOrCreate").mockResolvedValue(settings); + vi.spyOn(module.cases, "create").mockResolvedValue(entity); + vi.spyOn(profilesModule.profilesSettings, "getOrCreate").mockResolvedValue(profilesSettings); + vi.spyOn(requestsModule.requestsSettings, "getOrCreate").mockResolvedValue(requestsSettings); + }); + + describe("execute", () => { + beforeEach(() => { + command.client.api.guilds.addRoleToMember = vi.fn(); + command.module.createLogMessage = vi.fn(); + + vi.spyOn(command, "getOrCreateRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.channels, "createMessage").mockResolvedValue(mockMessage); + vi.spyOn(command.client.api.guilds, "get").mockResolvedValue(mockGuild); + vi.spyOn(command.client.api.guilds, "getMember").mockResolvedValue({ + ...mockMember, + user: { + ...mockUser, + id: command.client.applicationID + } + }); + vi.spyOn(command.client.api.users, "createDM").mockResolvedValue({ ...mockChannel, position: 0 }); + vi.spyOn(permissions, "isAboveMember").mockReturnValue(true); + }); + + it("should add the DWC role to the user", async () => { + await command.execute(interaction, options); + + expect(command.client.api.guilds.addRoleToMember).toHaveBeenCalledOnce(); + expect(command.client.api.guilds.addRoleToMember).toHaveBeenCalledWith( + interaction.guildID, + options.user.id, + mockRole.id, + { + reason: options.reason + } + ); + }); + + it("should notify the user that they have been flagged", async () => { + const createSpy = vi.spyOn(command.client.api.channels, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith(mockChannel.id, { + embeds: [{ + color: expect.any(Number), + description: expect.stringContaining("You have been marked with `Deal With Caution`"), + fields: [{ + name: "**Reason**", + value: options.reason + }] + }] + }); + }); + + it("should create a scheduled ban", async () => { + const createSpy = vi.spyOn(command.module.dwcScheduledBans, "create"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith(mockGuild.id, options.user.id); + }); + + it("should create a new case", async () => { + const createSpy = vi.spyOn(command.module.cases, "create"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + creatorID: interaction.user.id, + guildID: interaction.guildID, + note: options.reason, + type: CaseType.DWC, + userID: options.user.id + }); + }); + + it("should send a success message", async () => { + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining(`Case \`34\` | Successfully flagged \`${options.user.username}\`.`), + flags: MessageFlags.Ephemeral + }); + }); + + it("should log the case in the configured log channel", async () => { + await command.execute(interaction, options); + + expect(command.module.createLogMessage).toHaveBeenCalledOnce(); + expect(command.module.createLogMessage).toHaveBeenCalledWith(settings.channelID, { + case: entity, + creator: interaction.user, + reason: options.reason, + user: options.user + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + settings.channelID = null; + + await command.execute(interaction, options); + + expect(command.module.createLogMessage).not.toHaveBeenCalled(); + }); + + it("should flag the profile messages of the user", async () => { + await command.execute(interaction, options); + + expect(profilesModule.flagUser).toHaveBeenCalledOnce(); + expect(profilesModule.flagUser).toHaveBeenCalledWith( + interaction.guildID, + profilesSettings.channelID, + options.user, + options.reason + ); + }); + + it("should not flag the profile messages if the profiles module is not found", async () => { + marketplaceModule.dependencies.delete(profilesModule.id); + + await command.execute(interaction, options); + + expect(profilesModule.flagUser).not.toHaveBeenCalled(); + }); + + it("should not flag the profile messages if the channel is not configured", async () => { + profilesSettings.channelID = null; + + await command.execute(interaction, options); + + expect(profilesModule.flagUser).not.toHaveBeenCalled(); + }); + + it("should flag the requests of the user", async () => { + await command.execute(interaction, options); + + expect(requestsModule.flagUser).toHaveBeenCalledOnce(); + expect(requestsModule.flagUser).toHaveBeenCalledWith( + interaction.guildID, + profilesSettings.channelID, + options.user, + options.reason + ); + }); + + it("should not flag the requests if the profiles module is not found", async () => { + marketplaceModule.dependencies.delete(requestsModule.id); + + await command.execute(interaction, options); + + expect(requestsModule.flagUser).not.toHaveBeenCalled(); + }); + + it("should not flag the requests if the channel is not configured", async () => { + requestsSettings.channelID = null; + + await command.execute(interaction, options); + + expect(requestsModule.flagUser).not.toHaveBeenCalled(); + }); + + describe("Validation", () => { + it("should ignore if the interaction is not invoked in a guild", async () => { + delete interaction.guildID; + + await command.execute(interaction, options); + + expect(interaction.acknowledged).toBe(false); + }); + + it("should ignore if the command is not of type 'CHAT_INPUT'", async () => { + interaction.data.type = ApplicationCommandType.User; + + await command.execute(interaction, options); + + expect(interaction.acknowledged).toBe(false); + }); + + it("should show an error message if the user is trying to flag themselves", async () => { + const createSpy = vi.spyOn(interaction, "createMessage"); + options.user = interaction.user; + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("You cannot flag yourself."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show an error message if the user is trying to flag the bot", async () => { + const createSpy = vi.spyOn(interaction, "createMessage"); + options.user.id = command.client.applicationID; + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("Your attempt to flag me has been classified as a failed comedy show audition."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show an error message if the user is trying to flag a member that is above them", async () => { + vi.spyOn(permissions, "isAboveMember").mockReturnValue(false); + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("You cannot flag this member."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show an error message if the bot is trying to flag a member that is above them", async () => { + vi.spyOn(permissions, "isAboveMember") + .mockReturnValueOnce(true) + .mockReturnValue(false); + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("I cannot flag this member."), + flags: MessageFlags.Ephemeral + }); + }); + }); + + describe("Error Handling", () => { + it("should show an error message if the bot couldn't create the DWC role", async () => { + vi.spyOn(command, "getOrCreateRole").mockResolvedValue(undefined); + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("Failed to create the DWC role."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should show an error message if the bot couldn't add the DWC role to the user", async () => { + const error = new Error("Oh no!"); + vi.spyOn(command.client.api.guilds, "addRoleToMember").mockRejectedValue(error); + const createSpy = vi.spyOn(interaction, "createMessage"); + + await command.execute(interaction, options); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ + content: expect.stringContaining("Failed to add the DWC role to the member."), + flags: MessageFlags.Ephemeral + }); + }); + + it("should log an error if the message fails due to an unknown error", async () => { + const error = new Error("Oh no!"); + vi.spyOn(command.client.api.channels, "createMessage").mockRejectedValue(error); + + await command.execute(interaction, options); + + expect(command.client.logger.error).toHaveBeenCalledOnce(); + expect(command.client.logger.error).toHaveBeenCalledWith(error); + }); + + it("should not log an error if the user has their DMs disabled", async () => { + const response = { + code: 50007, + message: "Cannot send messages to this user" + }; + const error = new DiscordAPIError(response, 50007, 200, "POST", "", {}); + vi.spyOn(command.client.api.channels, "createMessage").mockRejectedValue(error); + + await command.execute(interaction, options); + + expect(command.client.logger.error).not.toHaveBeenCalled(); + }); + }); + }); + + describe("getOrCreateRole", () => { + it("should return the existing DWC role if it exists", async () => { + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([mockRole]); + + const role = await command.getOrCreateRole(settings); + + expect(role).toEqual(mockRole); + }); + + it("should create a new DWC role if it doesn't exist", async () => { + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const createSpy = vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + + const role = await command.getOrCreateRole(settings); + + expect(role).toEqual(mockRole); + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith(settings.guildID, { + color: expect.any(Number), + hoist: true, + name: "Deal With Caution" + }); + }); + + it("should update the settings with the new role ID", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const upsertSpy = vi.spyOn(command.module.moderationSettings, "upsert"); + + await command.getOrCreateRole(settings); + + expect(upsertSpy).toHaveBeenCalledOnce(); + expect(upsertSpy).toHaveBeenCalledWith(settings.guildID, { + dwcRoleID: mockRole.id + }); + }); + + it("should update the permissions of the profiles channel", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const updateSpy = vi.spyOn(command.client.api.channels, "editPermissionOverwrite"); + + await command.getOrCreateRole(settings, profilesSettings); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy).toHaveBeenCalledWith(profilesSettings.channelID, mockRole.id, { + deny: "1024", + type: OverwriteType.Role + }); + }); + + it("should not update the permissions of the profiles channel if the profiles module is not found", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const updateSpy = vi.spyOn(command.client.api.channels, "editPermissionOverwrite"); + + await command.getOrCreateRole(settings); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("should not update the permissions of the profiles channel if the channel is not configured", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const updateSpy = vi.spyOn(command.client.api.channels, "editPermissionOverwrite"); + profilesSettings.channelID = null; + + await command.getOrCreateRole(settings, profilesSettings); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("should update the permissions of the requests channel", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const updateSpy = vi.spyOn(command.client.api.channels, "editPermissionOverwrite"); + + await command.getOrCreateRole(settings, undefined, requestsSettings); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy).toHaveBeenCalledWith(requestsSettings.channelID, mockRole.id, { + deny: "1024", + type: OverwriteType.Role + }); + }); + + it("should not update the permissions of the requests channel if the requests module is not found", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const updateSpy = vi.spyOn(command.client.api.channels, "editPermissionOverwrite"); + + await command.getOrCreateRole(settings); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("should not update the permissions of the requests channel if the channel is not configured", async () => { + vi.spyOn(command.client.api.guilds, "createRole").mockResolvedValue(mockRole); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + const updateSpy = vi.spyOn(command.client.api.channels, "editPermissionOverwrite"); + requestsSettings.channelID = null; + + await command.getOrCreateRole(settings, undefined, requestsSettings); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("should log an error if the bot couldn't create the DWC role", async () => { + const error = new Error("Oh no!"); + vi.spyOn(command.client.api.guilds, "createRole").mockRejectedValue(error); + vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([]); + + await command.getOrCreateRole(settings, profilesSettings, requestsSettings); + + expect(command.client.logger.error).toHaveBeenCalledOnce(); + expect(command.client.logger.error).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/kick/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/kick/index.test.ts index 0d83d5e..7f75962 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/kick/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/kick/index.test.ts @@ -200,12 +200,22 @@ describe("/kick", () => { await command.execute(interaction, options); expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy).toHaveBeenCalledWith({ + expect(createSpy).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, reason: options.reason, user: options.member.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + vi.spyOn(permissions, "isAboveMember").mockReturnValue(true); + const createSpy = vi.spyOn(command.module, "createLogMessage"); + settings.channelID = null; + + await command.execute(interaction, options); + + expect(createSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/mute/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/mute/index.test.ts index bc4a236..e4abc2b 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/mute/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/mute/index.test.ts @@ -136,13 +136,23 @@ describe("/mute", () => { await command.execute(interaction, options); expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy).toHaveBeenCalledWith({ + expect(createSpy).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, duration: 300, reason: options.reason, user: options.member.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + vi.spyOn(permissions, "isAboveMember").mockReturnValue(true); + const createSpy = vi.spyOn(command.module, "createLogMessage"); + settings.channelID = null; + + await command.execute(interaction, options); + + expect(createSpy).not.toHaveBeenCalled(); }); describe("Validating", () => { diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/note/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/note/index.test.ts index 74fedd2..56e103e 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/note/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/note/index.test.ts @@ -65,16 +65,24 @@ describe("/note", () => { }); }); - it("should create a log message", async () => { + it("should log the case in the configured log channel", async () => { await command.execute(interaction, options); expect(command.module.createLogMessage).toHaveBeenCalledOnce(); - expect(command.module.createLogMessage).toHaveBeenCalledWith({ + expect(command.module.createLogMessage).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, reason: options.note, user: options.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + settings.channelID = null; + + await command.execute(interaction, options); + + expect(command.module.createLogMessage).not.toHaveBeenCalled(); }); it("should send a success message", async () => { diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/unban/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/unban/index.test.ts index ebb2d05..a99c26f 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/unban/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/unban/index.test.ts @@ -81,16 +81,24 @@ describe("/unban", () => { }); }); - it("should create a log message", async () => { + it("should log the case in the configured log channel", async () => { await command.execute(interaction, options); expect(command.module.createLogMessage).toHaveBeenCalledOnce(); - expect(command.module.createLogMessage).toHaveBeenCalledWith({ + expect(command.module.createLogMessage).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, reason: options.reason, user: options.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + settings.channelID = null; + + await command.execute(interaction, options); + + expect(command.module.createLogMessage).not.toHaveBeenCalled(); }); it("should ignore if the interaction was sent outside a guild", async () => { diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/unmute/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/unmute/index.test.ts index 6760cdd..cb2def8 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/unmute/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/unmute/index.test.ts @@ -97,16 +97,24 @@ describe("/unmute", () => { }); }); - it("should create a log message", async () => { + it("should log the case in the configured log channel", async () => { await command.execute(interaction, options); expect(command.module.createLogMessage).toHaveBeenCalledOnce(); - expect(command.module.createLogMessage).toHaveBeenCalledWith({ + expect(command.module.createLogMessage).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, reason: options.reason, user: options.member.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + settings.channelID = null; + + await command.execute(interaction, options); + + expect(command.module.createLogMessage).not.toHaveBeenCalled(); }); it("should ignore if the interaction was sent outside a guild", async () => { diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/warn/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/warn/index.test.ts index 06c0fdb..f5c68a9 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/warn/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/warn/index.test.ts @@ -185,12 +185,22 @@ describe("/warn", () => { await command.execute(interaction, options); expect(createSpy).toHaveBeenCalledOnce(); - expect(createSpy).toHaveBeenCalledWith({ + expect(createSpy).toHaveBeenCalledWith(settings.channelID, { case: entity, creator: interaction.user, reason: options.reason, user: options.member.user - }, settings); + }); + }); + + it("should not log the case if there is no log channel configured", async () => { + vi.spyOn(permissions, "isAboveMember").mockReturnValue(true); + const createSpy = vi.spyOn(command.module, "createLogMessage"); + settings.channelID = null; + + await command.execute(interaction, options); + + expect(createSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/barry/tests/modules/moderation/database.test.ts b/apps/barry/tests/modules/moderation/database.test.ts index 5e6e0a9..5ba098b 100644 --- a/apps/barry/tests/modules/moderation/database.test.ts +++ b/apps/barry/tests/modules/moderation/database.test.ts @@ -1,14 +1,17 @@ import { type Case, type CaseNote, + type DWCScheduledBan, type ModerationSettings, type TempBan, CaseType, Prisma } from "@prisma/client"; import { + type ExpiredDWCScheduledBan, CaseNoteRepository, CaseRepository, + DWCScheduledBanRepository, ModerationSettingsRepository, TempBanRepository } from "../../../src/modules/moderation/database.js"; @@ -431,6 +434,106 @@ describe("CaseNoteRepository", () => { }); }); +describe("DWCScheduledBanRepository", () => { + const guildID = "68239102456844360"; + const userID = "257522665437265920"; + + let repository: DWCScheduledBanRepository; + let mockBan: DWCScheduledBan; + + beforeEach(() => { + repository = new DWCScheduledBanRepository(prisma); + mockBan = { + createdAt: new Date(), + guildID: guildID, + userID: userID + }; + }); + + describe("create", () => { + it("should create a new scheduled ban record", async () => { + await repository.create(guildID, userID); + + expect(prisma.dWCScheduledBan.create).toHaveBeenCalledOnce(); + expect(prisma.dWCScheduledBan.create).toHaveBeenCalledWith({ + data: { guildID, userID } + }); + }); + }); + + describe("delete", () => { + it("should delete the specified scheduled ban record", async () => { + await repository.delete(guildID, userID); + + expect(prisma.dWCScheduledBan.delete).toHaveBeenCalledOnce(); + expect(prisma.dWCScheduledBan.delete).toHaveBeenCalledWith({ + where: { + guildID_userID: { + guildID, + userID + } + } + }); + }); + }); + + describe("get", () => { + it("should return the specified scheduled ban record", async () => { + vi.mocked(prisma.dWCScheduledBan.findUnique).mockResolvedValue(mockBan); + + const entity = await repository.get(guildID, userID); + + expect(entity).toEqual(mockBan); + expect(prisma.dWCScheduledBan.findUnique).toHaveBeenCalledOnce(); + expect(prisma.dWCScheduledBan.findUnique).toHaveBeenCalledWith({ + where: { + guildID_userID: { + guildID, + userID + } + } + }); + }); + + it("should return null if the scheduled ban record does not exist", async () => { + vi.mocked(prisma.dWCScheduledBan.findUnique).mockResolvedValue(null); + + const entity = await repository.get(guildID, userID); + + expect(entity).toEqual(null); + }); + }); + + describe("getExpired", () => { + it("should return all expired scheduled ban records", async () => { + const expiredBan: ExpiredDWCScheduledBan = { + channel_id: null, + created_at: new Date(), + dwc_role_id: null, + guild_id: guildID, + user_id: userID + }; + vi.mocked(prisma.$queryRaw).mockResolvedValue([expiredBan]); + + const entities = await repository.getExpired(); + + expect(entities).toEqual([expiredBan]); + expect(prisma.$queryRaw).toHaveBeenCalledOnce(); + expect(prisma.$queryRaw).toHaveBeenCalledWith([ + expect.stringContaining("WHERE d.created_at <= NOW() + (s.dwc_days * INTERVAL '1 day')") + ]); + }); + + it("should return an empty array if no scheduled ban records are expired", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([]); + + const entities = await repository.getExpired(); + + expect(entities).toEqual([]); + }); + }); +}); + describe("ModerationSettingsRepository", () => { const guildID = "68239102456844360"; diff --git a/apps/barry/tests/modules/moderation/index.test.ts b/apps/barry/tests/modules/moderation/index.test.ts index 833f4a6..f6ed9bb 100644 --- a/apps/barry/tests/modules/moderation/index.test.ts +++ b/apps/barry/tests/modules/moderation/index.test.ts @@ -1,33 +1,60 @@ -import { type ModerationSettings, CaseType } from "@prisma/client"; -import type { CaseLogOptions } from "../../../dist/modules/moderation/functions/getLogContent.js"; - +import type { APIGuildMember, APIUser } from "@discordjs/core"; import { + type ExpiredDWCScheduledBan, CaseNoteRepository, CaseRepository, + DWCScheduledBanRepository, ModerationSettingsRepository, TempBanRepository } from "../../../src/modules/moderation/database.js"; +import { + type ModerationSettings, + type ProfilesSettings, + type RequestsSettings, + CaseType +} from "@prisma/client"; +import type { CaseLogOptions } from "../../../dist/modules/moderation/functions/getLogContent.js"; + import { mockChannel, mockGuild, + mockMember, mockMessage, + mockRole, mockUser } from "@barry/testing"; import { DiscordAPIError } from "@discordjs/rest"; import { Module } from "@barry/core"; import { createMockApplication } from "../../mocks/application.js"; +import { mockCase } from "./mocks/case.js"; +import MarketplaceModule from "../../../src/modules/marketplace/index.js"; import ModerationModule from "../../../src/modules/moderation/index.js"; +import ProfilesModule from "../../../src/modules/marketplace/dependencies/profiles/index.js"; +import RequestsModule from "../../../src/modules/marketplace/dependencies/requests/index.js"; import * as content from "../../../src/modules/moderation/functions/getLogContent.js"; describe("ModerationModule", () => { + const guildID = "68239102456844360"; + const userID = "257522665437265920"; + + let mockBan: ExpiredDWCScheduledBan; let module: ModerationModule; let settings: ModerationSettings; beforeEach(() => { + vi.useFakeTimers().setSystemTime("01-01-2023"); + const client = createMockApplication(); module = new ModerationModule(client); + mockBan = { + channel_id: mockChannel.id, + created_at: new Date(), + dwc_role_id: mockRole.id, + guild_id: guildID, + user_id: userID + }; settings = { channelID: null, dwcDays: 7, @@ -35,6 +62,14 @@ describe("ModerationModule", () => { enabled: true, guildID: mockGuild.id }; + + vi.spyOn(module.tempBans, "getExpired").mockResolvedValue([{ + expiresAt: new Date(), + guildID: guildID, + userID: userID + }]); + + vi.spyOn(module.dwcScheduledBans, "getExpired").mockResolvedValue([mockBan]); }); afterEach(() => { @@ -45,15 +80,13 @@ describe("ModerationModule", () => { it("should set up the repositories correctly", () => { expect(module.caseNotes).toBeInstanceOf(CaseNoteRepository); expect(module.cases).toBeInstanceOf(CaseRepository); + expect(module.dwcScheduledBans).toBeInstanceOf(DWCScheduledBanRepository); expect(module.moderationSettings).toBeInstanceOf(ModerationSettingsRepository); expect(module.tempBans).toBeInstanceOf(TempBanRepository); }); }); describe("checkExpiredBans", () => { - const guildID = "68239102456844360"; - const userID = "257522665437265920"; - beforeEach(() => { module.createLogMessage = vi.fn(); vi.spyOn(module.client.api.guilds, "unbanUser").mockResolvedValue(); @@ -70,11 +103,6 @@ describe("ModerationModule", () => { userID: userID }); vi.spyOn(module.moderationSettings, "getOrCreate").mockResolvedValue(settings); - vi.spyOn(module.tempBans, "getExpired").mockResolvedValue([{ - expiresAt: new Date(), - guildID: guildID, - userID: userID - }]); settings.channelID = "30527482987641765"; }); @@ -144,26 +172,139 @@ describe("ModerationModule", () => { }); }); + describe("checkScheduledBans", () => { + let creator: APIUser; + let member: APIGuildMember; + + beforeEach(() => { + module.createLogMessage = vi.fn(); + module.punishFlaggedUser = vi.fn(); + module.unflagUser = vi.fn(); + + creator = { ...mockUser, id: module.client.applicationID }; + member = { ...mockMember, roles: [mockRole.id] }; + + vi.spyOn(module.client.api.guilds, "banUser").mockResolvedValue(); + vi.spyOn(module.client.api.guilds, "getMember").mockResolvedValue(member); + vi.spyOn(module.client.api.users, "get") + .mockResolvedValueOnce(creator) + .mockResolvedValue(mockUser); + + vi.spyOn(module.cases, "create").mockResolvedValue({ + createdAt: new Date(), + creatorID: module.client.applicationID, + guildID: guildID, + id: 1, + type: CaseType.Ban, + userID: userID + }); + vi.spyOn(module.moderationSettings, "getOrCreate").mockResolvedValue(settings); + + settings.channelID = "30527482987641765"; + }); + + it("should ban users whose scheduled ban has expired", async () => { + await module.checkScheduledBans(); + + expect(module.punishFlaggedUser).toHaveBeenCalledOnce(); + expect(module.punishFlaggedUser).toHaveBeenCalledWith(creator, member.user, mockBan); + expect(module.dwcScheduledBans.getExpired).toHaveBeenCalledOnce(); + expect(module.dwcScheduledBans.getExpired).toHaveBeenCalledWith(); + }); + + it("should unflag the user if the guild has not configured the DWC role", async () => { + mockBan.dwc_role_id = null; + + await module.checkScheduledBans(); + + expect(module.unflagUser).toHaveBeenCalledOnce(); + expect(module.unflagUser).toHaveBeenCalledWith(creator, member.user, mockBan); + }); + + it("should unflag the user if the role has been manually removed", async () => { + vi.mocked(module.client.api.guilds.getMember).mockResolvedValue({ ...mockMember, roles: [] }); + + await module.checkScheduledBans(); + + expect(module.unflagUser).toHaveBeenCalledOnce(); + expect(module.unflagUser).toHaveBeenCalledWith(creator, member.user, mockBan); + }); + + it("should punish the user if the user not being in the guild", async () => { + const response = { + code: 10007, + message: "Unknown Member" + }; + + const error = new DiscordAPIError(response, 10007, 404, "PUT", "", {}); + vi.mocked(module.client.api.guilds.getMember).mockRejectedValue(error); + + await module.checkScheduledBans(); + + expect(module.punishFlaggedUser).toHaveBeenCalledOnce(); + expect(module.punishFlaggedUser).toHaveBeenCalledWith(creator, mockUser, mockBan); + }); + + it("should delete the scheduled ban from the database", async () => { + const deleteSpy = vi.spyOn(module.dwcScheduledBans, "delete"); + + await module.checkScheduledBans(); + + expect(deleteSpy).toHaveBeenCalledOnce(); + expect(deleteSpy).toHaveBeenCalledWith(guildID, userID); + }); + + it("should ignore if there are no expired scheduled bans", async () => { + vi.spyOn(module.dwcScheduledBans, "getExpired").mockResolvedValue([]); + + await module.checkScheduledBans(); + + expect(module.punishFlaggedUser).not.toHaveBeenCalled(); + }); + + it("should throw an error if 'user' is missing on the member", async () => { + vi.mocked(module.client.api.guilds.getMember).mockResolvedValue({ ...mockMember, user: undefined }); + + await module.checkScheduledBans(); + + expect(module.client.logger.error).toHaveBeenCalledOnce(); + expect(module.client.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: "Missing required property 'user' on member." }) + ); + }); + + it("should log an error if the ban fails due to an unknown error", async () => { + const error = new Error("Oh no!"); + vi.mocked(module.punishFlaggedUser).mockRejectedValue(error); + + await module.checkScheduledBans(); + + expect(module.client.logger.error).toHaveBeenCalledOnce(); + expect(module.client.logger.error).toHaveBeenCalledWith(error); + }); + }); + describe("createLogMessage", () => { const channelID = "30527482987641765"; + let options: CaseLogOptions; beforeEach(() => { vi.spyOn(content, "getLogContent").mockReturnValue({ content: "Hello World!" }); - }); - it("should ignore if the log channel is not configured", async () => { - await module.createLogMessage({} as CaseLogOptions, settings); - - expect(content.getLogContent).not.toHaveBeenCalled(); + options = { + case: mockCase, + creator: mockUser, + reason: "Hello World!", + user: mockUser + }; }); it("should create a new message in the configured channel", async () => { const createSpy = vi.spyOn(module.client.api.channels, "createMessage"); - settings.channelID = channelID; - await module.createLogMessage({} as CaseLogOptions, settings); + await module.createLogMessage(channelID, options); expect(createSpy).toHaveBeenCalledOnce(); expect(createSpy).toHaveBeenCalledWith(channelID, { @@ -177,12 +318,11 @@ describe("ModerationModule", () => { message: "Unknown channel" }; - settings.channelID = channelID; const error = new DiscordAPIError(response, 10003, 404, "POST", "", {}); const updateSpy = vi.spyOn(module.moderationSettings, "upsert"); vi.spyOn(module.client.api.channels, "createMessage").mockRejectedValue(error); - await module.createLogMessage({} as CaseLogOptions, settings); + await module.createLogMessage(channelID, options); expect(updateSpy).toHaveBeenCalledOnce(); expect(updateSpy).toHaveBeenCalledWith(settings.guildID, { @@ -193,9 +333,8 @@ describe("ModerationModule", () => { it("should log an error if the message fails due to an unknown error", async () => { const error = new Error("Oh no!"); vi.spyOn(module.client.api.channels, "createMessage").mockRejectedValue(error); - settings.channelID = channelID; - await module.createLogMessage({} as CaseLogOptions, settings); + await module.createLogMessage(channelID, options); expect(module.client.logger.error).toHaveBeenCalledOnce(); expect(module.client.logger.error).toHaveBeenCalledWith(error); @@ -203,6 +342,11 @@ describe("ModerationModule", () => { }); describe("initialize", () => { + beforeEach(() => { + module.checkExpiredBans = vi.fn(); + module.checkScheduledBans = vi.fn(); + }); + it("should call super.initialize", async () => { const initSpy = vi.spyOn(Module.prototype, "initialize").mockResolvedValue(); @@ -213,15 +357,26 @@ describe("ModerationModule", () => { it("should set up the interval to check for expired bans", async () => { vi.useFakeTimers(); - const checkSpy = vi.spyOn(module, "checkExpiredBans").mockResolvedValue(); const intervalSpy = vi.spyOn(global, "setInterval"); await module.initialize(); vi.advanceTimersByTime(600000); - expect(intervalSpy).toHaveBeenCalledOnce(); + expect(intervalSpy).toHaveBeenCalledTimes(2); + expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), 600000); + expect(module.checkExpiredBans).toHaveBeenCalledOnce(); + }); + + it("should set up the interval to check for expired scheduled bans", async () => { + vi.useFakeTimers(); + const intervalSpy = vi.spyOn(global, "setInterval"); + + await module.initialize(); + vi.advanceTimersByTime(600000); + + expect(intervalSpy).toHaveBeenCalledTimes(2); expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), 600000); - expect(checkSpy).toHaveBeenCalledOnce(); + expect(module.checkScheduledBans).toHaveBeenCalledOnce(); }); }); @@ -382,4 +537,215 @@ describe("ModerationModule", () => { expect(module.client.logger.error).not.toHaveBeenCalled(); }); }); + + describe("punishFlaggedUser", () => { + let creator: APIUser; + + beforeEach(() => { + module.createLogMessage = vi.fn(); + module.notifyUser = vi.fn(); + module.unflagUser = vi.fn(); + + creator = { ...mockUser, id: module.client.applicationID }; + + vi.spyOn(module.client.api.guilds, "banUser").mockResolvedValue(); + vi.spyOn(module.client.api.guilds, "get").mockResolvedValue(mockGuild); + + vi.spyOn(module.cases, "create").mockResolvedValue({ + createdAt: new Date(), + creatorID: module.client.applicationID, + guildID: guildID, + id: 1, + type: CaseType.Ban, + userID: userID + }); + }); + + it("should ban the user", async () => { + await module.punishFlaggedUser(creator, mockUser, mockBan); + + expect(module.client.api.guilds.banUser).toHaveBeenCalledOnce(); + expect(module.client.api.guilds.banUser).toHaveBeenCalledWith(guildID, userID, {}, { + reason: "User did not resolve issue." + }); + }); + + it("should create a case for the ban", async () => { + await module.punishFlaggedUser(creator, mockUser, mockBan); + + expect(module.createLogMessage).toHaveBeenCalledOnce(); + expect(module.cases.create).toHaveBeenCalledOnce(); + expect(module.cases.create).toHaveBeenCalledWith({ + creatorID: module.client.applicationID, + guildID: guildID, + note: "User did not resolve issue.", + type: CaseType.Ban, + userID: userID + }); + }); + + it("should notify the user", async () => { + await module.punishFlaggedUser(creator, mockUser, mockBan); + + expect(module.notifyUser).toHaveBeenCalledOnce(); + expect(module.notifyUser).toHaveBeenCalledWith({ + guild: mockGuild, + reason: "User did not resolve issue.", + type: CaseType.Ban, + userID: userID + }); + }); + + it("should log the case in the configured log channel", async () => { + await module.punishFlaggedUser(creator, mockUser, mockBan); + + expect(module.createLogMessage).toHaveBeenCalledOnce(); + expect(module.createLogMessage).toHaveBeenCalledWith(mockBan.channel_id, { + case: expect.any(Object), + creator: creator, + reason: "User did not resolve issue.", + user: mockUser + }); + }); + + it("should not log the case if the guild has not configured a log channel", async () => { + mockBan.channel_id = null; + + await module.punishFlaggedUser(creator, mockUser, mockBan); + + expect(module.createLogMessage).not.toHaveBeenCalled(); + }); + }); + + describe("unflagUser", () => { + const channelID = "30527482987641765"; + + let creator: APIUser; + + let marketplaceModule: MarketplaceModule; + let profilesModule: ProfilesModule; + let profilesSettings: ProfilesSettings; + let requestsModule: RequestsModule; + let requestsSettings: RequestsSettings; + + beforeEach(async () => { + module.createLogMessage = vi.fn(); + + marketplaceModule = new MarketplaceModule(module.client); + profilesModule = new ProfilesModule(module.client); + requestsModule = new RequestsModule(module.client); + + profilesModule.unflagUser = vi.fn(); + requestsModule.unflagUser = vi.fn(); + + await marketplaceModule.dependencies.add(profilesModule); + await marketplaceModule.dependencies.add(requestsModule); + await module.client.modules.add(marketplaceModule); + + creator = { ...mockUser, id: module.client.applicationID }; + profilesSettings = { + channelID: channelID, + enabled: true, + guildID: guildID, + lastMessageID: null + }; + requestsSettings = { + channelID: channelID, + enabled: true, + guildID: guildID, + lastMessageID: null, + minCompensation: 50 + }; + + vi.spyOn(module.cases, "create").mockResolvedValue({ + createdAt: new Date(), + creatorID: module.client.applicationID, + guildID: guildID, + id: 1, + type: CaseType.UnDWC, + userID: userID + }); + vi.spyOn(profilesModule.profilesSettings, "getOrCreate").mockResolvedValue(profilesSettings); + vi.spyOn(requestsModule.requestsSettings, "getOrCreate").mockResolvedValue(requestsSettings); + }); + + it("should unflag the user's profile", async () => { + await module.unflagUser(creator, mockUser, mockBan); + + expect(profilesModule.unflagUser).toHaveBeenCalledOnce(); + expect(profilesModule.unflagUser).toHaveBeenCalledWith(guildID, channelID, mockUser); + }); + + it("should unflag the user's requests", async () => { + await module.unflagUser(creator, mockUser, mockBan); + + expect(requestsModule.unflagUser).toHaveBeenCalledOnce(); + expect(requestsModule.unflagUser).toHaveBeenCalledWith(guildID, channelID, mockUser); + }); + + it("should create a case for the unflag", async () => { + await module.unflagUser(creator, mockUser, mockBan); + + expect(module.cases.create).toHaveBeenCalledOnce(); + expect(module.cases.create).toHaveBeenCalledWith({ + creatorID: module.client.applicationID, + guildID: guildID, + note: "The DWC role was removed manually. The user will not be banned.", + type: CaseType.UnDWC, + userID: mockUser.id + }); + }); + + it("should log the case in the configured log channel", async () => { + await module.unflagUser(creator, mockUser, mockBan); + + expect(module.createLogMessage).toHaveBeenCalledOnce(); + expect(module.createLogMessage).toHaveBeenCalledWith(channelID, { + case: expect.any(Object), + creator: creator, + reason: "The DWC role was removed manually. The user will not be banned.", + user: mockUser + }); + }); + + it("should not log the case if the guild has not configured a log channel", async () => { + mockBan.channel_id = null; + + await module.unflagUser(creator, mockUser, mockBan); + + expect(module.createLogMessage).not.toHaveBeenCalled(); + }); + + it("should not unflag the profile if the module is not found", async () => { + marketplaceModule.dependencies.delete(profilesModule.id); + + await module.unflagUser(creator, mockUser, mockBan); + + expect(profilesModule.unflagUser).not.toHaveBeenCalled(); + }); + + it("should not unflag the requests if the module is not found", async () => { + marketplaceModule.dependencies.delete(requestsModule.id); + + await module.unflagUser(creator, mockUser, mockBan); + + expect(requestsModule.unflagUser).not.toHaveBeenCalled(); + }); + + it("should not unflag the profile if the channel is unknown", async () => { + profilesSettings.channelID = null; + + await module.unflagUser(creator, mockUser, mockBan); + + expect(profilesModule.unflagUser).not.toHaveBeenCalled(); + }); + + it("should not unflag the requests if the channel is unknown", async () => { + requestsSettings.channelID = null; + + await module.unflagUser(creator, mockUser, mockBan); + + expect(requestsModule.unflagUser).not.toHaveBeenCalled(); + }); + }); }); From ee8830c2f6b2cc42d3d81f77bc7424c5abd0cd39 Mon Sep 17 00:00:00 2001 From: HeadTriXz Date: Fri, 15 Sep 2023 16:14:04 +0200 Subject: [PATCH 2/2] tests: Add missing tests --- .../commands/chatinput/dwc/index.test.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts b/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts index 3d9b5de..15da4f5 100644 --- a/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts +++ b/apps/barry/tests/modules/moderation/commands/chatinput/dwc/index.test.ts @@ -13,6 +13,7 @@ import { } from "@discordjs/core"; import { createMockApplicationCommandInteraction, + createMockAutocompleteInteraction, mockChannel, mockGuild, mockMember, @@ -21,7 +22,7 @@ import { mockUser } from "@barry/testing"; -import { ApplicationCommandInteraction } from "@barry/core"; +import { ApplicationCommandInteraction, AutocompleteInteraction } from "@barry/core"; import { DiscordAPIError } from "@discordjs/rest"; import { createMockApplication } from "../../../../../mocks/application.js"; import { mockCase } from "../../../mocks/case.js"; @@ -32,6 +33,7 @@ import ModerationModule from "../../../../../../src/modules/moderation/index.js" import ProfilesModule from "../../../../../../src/modules/marketplace/dependencies/profiles/index.js"; import RequestsModule from "../../../../../../src/modules/marketplace/dependencies/requests/index.js"; import * as permissions from "../../../../../../src/modules/moderation/functions/permissions.js"; +import { COMMON_DWC_REASONS } from "../../../../../../src/modules/moderation/constants.js"; describe("/dwc", () => { let command: DWCCommand; @@ -394,6 +396,10 @@ describe("/dwc", () => { }); describe("getOrCreateRole", () => { + beforeEach(() => { + command.client.api.channels.editPermissionOverwrite = vi.fn(); + }); + it("should return the existing DWC role if it exists", async () => { vi.spyOn(command.client.api.guilds, "getRoles").mockResolvedValue([mockRole]); @@ -511,4 +517,40 @@ describe("/dwc", () => { expect(command.client.logger.error).toHaveBeenCalledWith(error); }); }); + + describe("Autocomplete 'reason'", () => { + it("should show a predefined list of reasons if no value is provided", async () => { + const data = createMockAutocompleteInteraction({ + id: "49072635294295155", + name: "warn", + options: [], + type: ApplicationCommandType.ChatInput + }); + const interaction = new AutocompleteInteraction(data, command.client, vi.fn()); + + const result = await command.options[1].autocomplete?.("" as never, interaction); + + expect(result).toEqual(COMMON_DWC_REASONS.map((x) => ({ name: x, value: x }))); + }); + + it("should show a matching predefined option if the option starts with the value", async () => { + const data = createMockAutocompleteInteraction({ + id: "49072635294295155", + name: "warn", + options: [], + type: ApplicationCommandType.ChatInput + }); + const interaction = new AutocompleteInteraction(data, command.client, vi.fn()); + + const result = await command.options[1].autocomplete?.( + COMMON_DWC_REASONS[0].slice(0, 5) as never, + interaction + ); + + expect(result).toEqual([{ + name: COMMON_DWC_REASONS[0], + value: COMMON_DWC_REASONS[0] + }]); + }); + }); });