diff --git a/src/commands/timeout.ts b/src/commands/timeout.ts new file mode 100644 index 0000000..65e3ba0 --- /dev/null +++ b/src/commands/timeout.ts @@ -0,0 +1,151 @@ +import { CacheType, CommandInteraction, EmbedBuilder, GuildMember, PermissionsBitField, SlashCommandBuilder, TextChannel } from "discord.js"; +import { AuditType } from "../constants/AuditType"; +import EmbedColours from "../constants/EmbedColours"; +import Audit from "../database/entities/Audit"; +import SettingsHelper from "../helpers/SettingsHelper"; +import TimeLengthInput from "../helpers/TimeLengthInput"; +import { Command } from "../type/command"; + +export default class Timeout extends Command { + constructor() { + super(); + + super.CommandBuilder = new SlashCommandBuilder() + .setName("timeout") + .setDescription("Timeouts a user out, sending them a DM with the reason if possible") + .setDefaultMemberPermissions(PermissionsBitField.Flags.ModerateMembers) + .addUserOption(option => + option + .setName('target') + .setDescription('The user') + .setRequired(true)) + .addStringOption(option => + option + .setName("length") + .setDescription("How long to timeout for? (Example: 24h, 60m)") + .setRequired(true)) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason')); + } + + public override async execute(interaction: CommandInteraction) { + if (!interaction.guild || !interaction.guildId) return; + + // Interaction Inputs + const targetUser = interaction.options.get('target'); + const lengthInput = interaction.options.get('length'); + const reasonInput = interaction.options.get('reason'); + + // Validation + if (!targetUser || !targetUser.user || !targetUser.member) { + await interaction.reply('Fields are required.'); + return; + } + + if (!lengthInput || !lengthInput.value) { + await interaction.reply('Fields are required.'); + return; + } + + // General Variables + const targetMember = targetUser.member as GuildMember; + const reason = reasonInput && reasonInput.value ? reasonInput.value.toString() : null; + + const timeLength = new TimeLengthInput(lengthInput.value.toString()); + + const logEmbed = new EmbedBuilder() + .setColor(EmbedColours.Ok) + .setTitle("Member Timed Out") + .setDescription(`<@${targetUser.user.id}> \`${targetUser.user.tag}\``) + .addFields([ + { + name: "Moderator", + value: `<@${interaction.user.id}>`, + }, + { + name: "Reason", + value: reason || "*none*", + }, + { + name: "Length", + value: timeLength.GetLengthShort(), + }, + { + name: "Until", + value: timeLength.GetDateFromNow().toString(), + }, + ]); + + // Bot Permissions Check + if (!targetMember.manageable) { + await interaction.reply('Insufficient bot permissions. Please contact a moderator.'); + return; + } + + // Execute Timeout + await targetMember.timeout(timeLength.GetMilliseconds(), reason || ""); + + // Log Embed To Channel + const channelName = await SettingsHelper.GetSetting('channels.logs.mod', interaction.guildId); + + if (!channelName) return; + + const channel = interaction.guild.channels.cache.find(x => x.name == channelName) as TextChannel; + + if (channel) { + await channel.send({ embeds: [ logEmbed ]}); + } + + // Create Audit + const audit = new Audit(targetUser.user.id, AuditType.Timeout, reason || "*none*", interaction.user.id, interaction.guildId); + await audit.Save(Audit, audit); + + // DM User, if possible + const resultEmbed = new EmbedBuilder() + .setColor(EmbedColours.Ok) + .setDescription(`<@${targetUser.user.id}> has been timed out`); + + const dmEmbed = new EmbedBuilder() + .setColor(EmbedColours.Ok) + .setDescription(`You have been timed out in ${interaction.guild.name}`) + .addFields([ + { + name: "Reason", + value: reason || "*none*" + }, + { + name: "Length", + value: timeLength.GetLengthShort(), + }, + { + name: "Until", + value: timeLength.GetDateFromNow().toString(), + }, + ]); + + try { + const dmChannel = await targetUser.user.createDM(); + + await dmChannel.send({ embeds: [ dmEmbed ]}); + + resultEmbed.addFields([ + { + name: "DM Sent", + value: "true", + }, + ]); + } catch { + resultEmbed.addFields([ + { + name: "DM Sent", + value: "false", + }, + ]); + } + + // Success Reply + await interaction.reply({ embeds: [ resultEmbed ]}); + } +} \ No newline at end of file diff --git a/src/constants/AuditType.ts b/src/constants/AuditType.ts index 4ad8df6..0fd7325 100644 --- a/src/constants/AuditType.ts +++ b/src/constants/AuditType.ts @@ -4,4 +4,5 @@ export enum AuditType { Mute, Kick, Ban, + Timeout, } \ No newline at end of file diff --git a/src/helpers/AuditTools.ts b/src/helpers/AuditTools.ts index fa06c43..9833a3c 100644 --- a/src/helpers/AuditTools.ts +++ b/src/helpers/AuditTools.ts @@ -13,6 +13,8 @@ export default class AuditTools { return "Kick"; case AuditType.Ban: return "Ban"; + case AuditType.Timeout: + return "Timeout"; default: return "Other"; } @@ -30,6 +32,8 @@ export default class AuditTools { return AuditType.Kick; case "ban": return AuditType.Ban; + case "timeout": + return AuditType.Timeout; default: return AuditType.General; } diff --git a/src/helpers/TimeLengthInput.ts b/src/helpers/TimeLengthInput.ts new file mode 100644 index 0000000..7c167d2 --- /dev/null +++ b/src/helpers/TimeLengthInput.ts @@ -0,0 +1,119 @@ +export default class TimeLengthInput { + public readonly value: string; + + constructor(input: string) { + this.value = input; + } + + public GetDays(): number { + return this.GetValue('d'); + } + + public GetHours(): number { + return this.GetValue('h'); + } + + public GetMinutes(): number { + return this.GetValue('m'); + } + + public GetSeconds(): number { + return this.GetValue('s'); + } + + public GetMilliseconds(): number { + const days = this.GetDays(); + const hours = this.GetHours(); + const minutes = this.GetMinutes(); + const seconds = this.GetSeconds(); + + let milliseconds = 0; + + milliseconds += seconds * 1000; + milliseconds += minutes * 60 * 1000; + milliseconds += hours * 60 * 60 * 1000; + milliseconds += days * 24 * 60 * 60 * 1000; + + return milliseconds; + } + + public GetDateFromNow(): Date { + const now = Date.now(); + + const dateFromNow = now + + (1000 * this.GetSeconds()) + + (1000 * 60 * this.GetMinutes()) + + (1000 * 60 * 60 * this.GetHours()) + + (1000 * 60 * 60 * 24 * this.GetDays()); + + return new Date(dateFromNow); + } + + public GetLength(): string { + const days = this.GetDays(); + const hours = this.GetHours(); + const minutes = this.GetMinutes(); + const seconds = this.GetSeconds(); + + const value = []; + + if (days) { + value.push(`${days} days`); + } + + if (hours) { + value.push(`${hours} hours`); + } + + if (minutes) { + value.push(`${minutes} minutes`); + } + + if (seconds) { + value.push(`${seconds} seconds`); + } + + return value.join(", "); + } + + public GetLengthShort(): string { + const days = this.GetDays(); + const hours = this.GetHours(); + const minutes = this.GetMinutes(); + const seconds = this.GetSeconds(); + + const value = []; + + if (days) { + value.push(`${days}d`); + } + + if (hours) { + value.push(`${hours}h`); + } + + if (minutes) { + value.push(`${minutes}m`); + } + + if (seconds) { + value.push(`${seconds}s`); + } + + return value.join(" "); + } + + private GetValue(designation: string): number { + const valueSplit = this.value.split(' '); + + const desString = valueSplit.find(x => x.charAt(x.length - 1) == designation); + + if (!desString) return 0; + + const desNumber = Number(desString.substring(0, desString.length - 1)); + + if (!desNumber) return 0; + + return desNumber; + } +} \ No newline at end of file diff --git a/src/registry.ts b/src/registry.ts index a86ce96..50beeab 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -17,6 +17,7 @@ import Role from "./commands/Role/role"; import ConfigRole from "./commands/Role/config"; import Rules from "./commands/rules"; import Setup from "./commands/setup"; +import Timeout from "./commands/timeout"; import Unmute from "./commands/unmute"; import Warn from "./commands/warn"; @@ -38,6 +39,7 @@ import MessageCreate from "./events/MessageEvents/MessageCreate"; export default class Registry { public static RegisterCommands() { CoreClient.RegisterCommand("about", new About()); + CoreClient.RegisterCommand("audits", new Audits()); CoreClient.RegisterCommand("ban", new Ban()); CoreClient.RegisterCommand("bunny", new Bunny()); CoreClient.RegisterCommand("clear", new Clear()); @@ -48,10 +50,10 @@ export default class Registry { CoreClient.RegisterCommand("kick", new Kick()); CoreClient.RegisterCommand("mute", new Mute()); CoreClient.RegisterCommand("rules", new Rules()); + CoreClient.RegisterCommand("setup", new Setup()); + CoreClient.RegisterCommand("timeout", new Timeout()); CoreClient.RegisterCommand("unmute", new Unmute()); CoreClient.RegisterCommand("warn", new Warn()); - CoreClient.RegisterCommand("setup", new Setup()); - CoreClient.RegisterCommand("audits", new Audits()); CoreClient.RegisterCommand("role", new Role()); CoreClient.RegisterCommand("configrole", new ConfigRole()); diff --git a/tests/commands/timeout.test.ts b/tests/commands/timeout.test.ts new file mode 100644 index 0000000..c64ceb5 --- /dev/null +++ b/tests/commands/timeout.test.ts @@ -0,0 +1,729 @@ +import { APIEmbed, CacheType, CommandInteraction, CommandInteractionOption, DMChannel, Embed, EmbedBuilder, EmbedField, Guild, GuildChannel, GuildMember, InteractionReplyOptions, JSONEncodable, Message, MessageCreateOptions, MessagePayload, SlashCommandBuilder, TextChannel, User } from "discord.js"; +import { mock } from "jest-mock-extended"; +import Timeout from "../../src/commands/timeout"; +import SettingsHelper from "../../src/helpers/SettingsHelper"; +import Audit from "../../src/database/entities/Audit"; +import EmbedColours from "../../src/constants/EmbedColours"; +import { DeepPartial, EntityTarget } from "typeorm"; +import BaseEntity from "../../src/contracts/BaseEntity"; +import { AuditType } from "../../src/constants/AuditType"; + +describe('Constructor', () => { + test('EXPECT CommandBuilder to be configured', () => { + const command = new Timeout(); + + expect(command.CommandBuilder).toBeDefined(); + + const commandBuilder = command.CommandBuilder as SlashCommandBuilder; + + expect(commandBuilder.name).toBe("timeout"); + expect(commandBuilder.description).toBe("Timeouts a user out, sending them a DM with the reason if possible"); + expect(commandBuilder.options.length).toBe(3); + }); +}); + +describe('execute', () => { + // Happy flow + test('GIVEN all checks have passed, EXPECT user to be timed out', async () => { + let embeds: APIEmbed[] | undefined; + + const command = new Timeout(); + + const interactionReply = jest.fn((options: InteractionReplyOptions) => { + embeds = options.embeds as APIEmbed[]; + }); + + let savedAudit: DeepPartial | undefined; + + const getSetting = jest.spyOn(SettingsHelper, 'GetSetting').mockResolvedValue('mod-logs'); + const auditSave = jest.spyOn(Audit.prototype, 'Save').mockImplementation((target: EntityTarget, entity: DeepPartial): Promise => { + savedAudit = entity; + + return Promise.resolve(); + }); + + const timeoutFunc = jest.fn(); + + let dmChannelSentEmbeds: (APIEmbed | JSONEncodable)[] | undefined; + let logsChannelSentEmbeds: (APIEmbed | JSONEncodable)[] | undefined; + + const dmChannel = { + send: jest.fn().mockImplementation((options: MessageCreateOptions) => { + dmChannelSentEmbeds = options.embeds; + }), + } as unknown as DMChannel; + + const userInput = { + user: { + id: 'userId', + tag: 'userTag', + createDM: jest.fn().mockResolvedValue(dmChannel), + } as unknown as User, + member: { + manageable: true, + timeout: timeoutFunc, + } as unknown as GuildMember, + } as CommandInteractionOption; + + const lengthInput = { + value: '1s', + } as CommandInteractionOption; + + const reasonInput = { + value: 'Test reason', + } as CommandInteractionOption; + + const logsChannel = { + name: 'mod-logs', + send: jest.fn().mockImplementation((options: MessageCreateOptions) => { + logsChannelSentEmbeds = options.embeds; + }), + } as unknown as TextChannel; + + const interaction = { + guild: { + channels: { + cache: { + find: jest.fn() + .mockReturnValue(logsChannel), + } + }, + name: "Test Guild", + } as unknown as Guild, + guildId: 'guildId', + reply: interactionReply, + options: { + get: jest.fn() + .mockReturnValueOnce(userInput) + .mockReturnValueOnce(lengthInput) + .mockReturnValue(reasonInput), + }, + user: { + id: 'moderatorId' + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + // EXPECT user to be timed out + expect(timeoutFunc).toBeCalledWith(1000, 'Test reason'); + + // EXPECT embeds to be sent + expect(embeds).toBeDefined(); + expect(embeds!.length).toBe(1); + + // EXPECT resultEmbed to be correctly configured + const resultEmbed = embeds![0] as EmbedBuilder; + + expect(resultEmbed.data.description).toBe('<@userId> has been timed out'); + expect(resultEmbed.data.fields).toBeDefined(); + expect(resultEmbed.data.fields!.length).toBe(1); + + // EXPECT DM field to be configured + const resultEmbedDMField = resultEmbed.data.fields![0]; + + expect(resultEmbedDMField.name).toBe("DM Sent"); + expect(resultEmbedDMField.value).toBe("true"); + + // EXPECT user to be DM's with embed + expect(dmChannel.send).toBeCalled(); + expect(dmChannelSentEmbeds).toBeDefined(); + expect(dmChannelSentEmbeds?.length).toBe(1); + + const dmChannelSentEmbed = (dmChannelSentEmbeds![0] as any).data; + + expect(dmChannelSentEmbed.color).toBe(EmbedColours.Ok); + expect(dmChannelSentEmbed.description).toBe("You have been timed out in Test Guild"); + expect(dmChannelSentEmbed.fields?.length).toBe(3); + + expect(dmChannelSentEmbed.fields![0].name).toBe("Reason"); + expect(dmChannelSentEmbed.fields![0].value).toBe("Test reason"); + + expect(dmChannelSentEmbed.fields![1].name).toBe("Length"); + expect(dmChannelSentEmbed.fields![1].value).toBe("1s"); + + expect(dmChannelSentEmbed.fields![2].name).toBe("Until"); + expect(dmChannelSentEmbed.fields![2].value).toBeDefined(); + + // EXPECT log embed to be sent + expect(logsChannel.send).toBeCalled(); + expect(logsChannelSentEmbeds).toBeDefined(); + expect(logsChannelSentEmbeds?.length).toBe(1); + + const logsChannelSentEmbed = (logsChannelSentEmbeds![0] as any).data; + + expect(logsChannelSentEmbed.color).toBe(EmbedColours.Ok); + expect(logsChannelSentEmbed.title).toBe("Member Timed Out"); + expect(logsChannelSentEmbed.description).toBe("<@userId> `userTag`"); + expect(logsChannelSentEmbed.fields?.length).toBe(4); + + expect(logsChannelSentEmbed.fields![0].name).toBe("Moderator"); + expect(logsChannelSentEmbed.fields![0].value).toBe("<@moderatorId>"); + + expect(logsChannelSentEmbed.fields![1].name).toBe("Reason"); + expect(logsChannelSentEmbed.fields![1].value).toBe("Test reason"); + + expect(logsChannelSentEmbed.fields![2].name).toBe("Length"); + expect(logsChannelSentEmbed.fields![2].value).toBe("1s"); + + expect(logsChannelSentEmbed.fields![3].name).toBe("Until"); + expect(logsChannelSentEmbed.fields![3].value).toBeDefined(); + + // EXPECT Audit to be saved + expect(auditSave).toBeCalled(); + + expect(savedAudit).toBeDefined(); + expect(savedAudit?.UserId).toBe('userId'); + expect(savedAudit?.AuditType).toBe(AuditType.Timeout); + expect(savedAudit?.Reason).toBe("Test reason"); + expect(savedAudit?.ModeratorId).toBe('moderatorId'); + expect(savedAudit?.ServerId).toBe('guildId'); + }); + + // Null checks + test('GIVEN interaction.guild IS NULL, EXPECT nothing to happen', async () => { + const command = new Timeout(); + + const interaction = { + guild: null, + reply: jest.fn(), + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).not.toBeCalled(); + }); + + test('GIVEN interaction.guildId IS NULL, EXPECT nothing to happen', async () => { + const command = new Timeout(); + + const interaction = { + guild: mock(), + guildId: null, + reply: jest.fn(), + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).not.toBeCalled(); + }); + + // Validation + test('GIVEN targetUser IS NULL, EXPECT validation error', async () => { + const command = new Timeout(); + + const interaction = { + reply: jest.fn(), + guild: mock(), + guildId: 'guildId', + options: { + get: jest.fn().mockReturnValue(undefined), + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).toBeCalledWith('Fields are required.'); + }); + + test('GIVEN targetUser.user IS NULL, EXPECT validation error', async () => { + const command = new Timeout(); + + const interaction = { + reply: jest.fn(), + guild: mock(), + guildId: 'guildId', + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return {} as CommandInteractionOption; + case 'length': + return { + value: '1m', + } as CommandInteractionOption; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).toBeCalledWith('Fields are required.'); + }); + + test('GIVEN targetUser.member IS NULL, EXPECT validation error', async () => { + const command = new Timeout(); + + const interaction = { + reply: jest.fn(), + guild: mock(), + guildId: 'guildId', + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: {} as User, + } as CommandInteractionOption; + case 'length': + return { + value: '1m', + } as CommandInteractionOption; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).toBeCalledWith('Fields are required.'); + }); + + test('GIVEN lengthInput IS NULL, EXPECT validation error', async () => { + const command = new Timeout(); + + const interaction = { + reply: jest.fn(), + guild: mock(), + guildId: 'guildId', + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: {} as User, + member: {} as GuildMember + } as CommandInteractionOption; + case 'length': + return null; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).toBeCalledWith('Fields are required.'); + }); + + test('GIVEN lengthInput.value IS NULL, EXPECT validation error', async () => { + const command = new Timeout(); + + const interaction = { + reply: jest.fn(), + guild: mock(), + guildId: 'guildId', + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: {} as User, + member: {} as GuildMember + } as CommandInteractionOption; + case 'length': + return { + value: undefined, + } as CommandInteractionOption; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).toBeCalledWith('Fields are required.'); + }); + + test('GIVEN targetMember IS NOT manageable by the bot, EXPECT insufficient permissions error', async () => { + const command = new Timeout(); + + const interaction = { + reply: jest.fn(), + guild: mock(), + guildId: 'guildId', + user: { + id: 'moderatorId', + }, + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: { + id: 'userId', + tag: 'userTag', + } as User, + member: { + manageable: false, + } as GuildMember + } as CommandInteractionOption; + case 'length': + return { + value: '1m', + } as CommandInteractionOption; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + expect(interaction.reply).toBeCalledWith('Insufficient bot permissions. Please contact a moderator.'); + }); + + // Reason variable + test('GIVEN reason IS NULL, EXPECT to be ran with empty string', async () => { + const command = new Timeout(); + + let savedAudit: DeepPartial | undefined; + + const auditSave = jest.spyOn(Audit.prototype, 'Save').mockImplementation((target: EntityTarget, entity: DeepPartial): Promise => { + savedAudit = entity; + + return Promise.resolve(); + }); + + const timeoutFunc = jest.fn(); + + const sentEmbeds: EmbedBuilder[] = []; + + const interaction = { + reply: jest.fn(), + guild: { + channels: { + cache: { + find: jest.fn().mockReturnValue(mock()), + } + } + }, + guildId: 'guildId', + user: { + id: 'moderatorId', + }, + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: { + id: 'userId', + tag: 'userTag', + createDM: jest.fn().mockReturnValue({ + send: jest.fn(async (options: MessageCreateOptions): Promise> => { + sentEmbeds.push(options.embeds![0] as EmbedBuilder); + + return mock>(); + }) + }) as unknown as DMChannel, + } as unknown as User, + member: { + manageable: true, + timeout: timeoutFunc, + } as unknown as GuildMember + } as CommandInteractionOption; + case 'length': + return { + value: '1m' + } as CommandInteractionOption; + case 'reason': + return { + value: undefined, + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + + await command.execute(interaction); + + expect(timeoutFunc).toBeCalledWith(1000 * 60 * 1, ""); + expect(savedAudit?.Reason).toBe("*none*"); + + const dmEmbed = (sentEmbeds[0] as any).data; + const dmEmbedReasonField = dmEmbed.fields![0] as EmbedField; + + expect(dmEmbedReasonField.value).toBe("*none*"); + }); + + // Log embed + test('GIVEN channelName IS NULL, EXPECT execution to return', async () => { + const command = new Timeout(); + + let savedAudit: DeepPartial | undefined; + + const auditSave = jest.spyOn(Audit.prototype, 'Save').mockImplementation((target: EntityTarget, entity: DeepPartial): Promise => { + savedAudit = entity; + + return Promise.resolve(); + }); + + const settingsGet = jest.spyOn(SettingsHelper, 'GetSetting').mockResolvedValue(undefined); + + const timeoutFunc = jest.fn(); + + const sentEmbeds: EmbedBuilder[] = []; + + const logChannelSendFunc = jest.fn(); + + const interaction = { + reply: jest.fn(), + guild: { + channels: { + cache: { + find: jest.fn().mockReturnValue({ + send: logChannelSendFunc, + } as unknown as TextChannel), + } + } + }, + guildId: 'guildId', + user: { + id: 'moderatorId', + }, + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: { + id: 'userId', + tag: 'userTag', + createDM: jest.fn().mockReturnValue({ + send: jest.fn(async (options: MessageCreateOptions): Promise> => { + sentEmbeds.push(options.embeds![0] as EmbedBuilder); + + return mock>(); + }) + }) as unknown as DMChannel, + } as unknown as User, + member: { + manageable: true, + timeout: timeoutFunc, + } as unknown as GuildMember + } as CommandInteractionOption; + case 'length': + return { + value: '1m' + } as CommandInteractionOption; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + + await command.execute(interaction); + + expect(timeoutFunc).toBeCalled(); + expect(sentEmbeds.length).toBe(0); + expect(logChannelSendFunc).not.toBeCalled(); + }); + + test('GIVEN channel IS NULL, EXPECT embed to not be sent', async () => { + const command = new Timeout(); + + let savedAudit: DeepPartial | undefined; + + const auditSave = jest.spyOn(Audit.prototype, 'Save').mockImplementation((target: EntityTarget, entity: DeepPartial): Promise => { + savedAudit = entity; + + return Promise.resolve(); + }); + + const settingsGet = jest.spyOn(SettingsHelper, 'GetSetting').mockResolvedValue('mod-logs'); + + const timeoutFunc = jest.fn(); + + const sentEmbeds: EmbedBuilder[] = []; + + const interaction = { + reply: jest.fn(), + guild: { + channels: { + cache: { + find: jest.fn().mockReturnValue(undefined), + } + } + }, + guildId: 'guildId', + user: { + id: 'moderatorId', + }, + options: { + get: jest.fn((value: string): CommandInteractionOption | null => { + switch (value) { + case 'target': + return { + user: { + id: 'userId', + tag: 'userTag', + createDM: jest.fn().mockReturnValue({ + send: jest.fn(async (options: MessageCreateOptions): Promise> => { + sentEmbeds.push(options.embeds![0] as EmbedBuilder); + + return mock>(); + }) + }) as unknown as DMChannel, + } as unknown as User, + member: { + manageable: true, + timeout: timeoutFunc, + } as unknown as GuildMember + } as CommandInteractionOption; + case 'length': + return { + value: '1m' + } as CommandInteractionOption; + case 'reason': + return { + value: 'Test reason', + } as CommandInteractionOption; + default: + return null; + } + }), + } + } as unknown as CommandInteraction; + + + await command.execute(interaction); + + expect(timeoutFunc).toBeCalled(); + expect(sentEmbeds.length).toBeGreaterThan(0); + }); + + // DM user + test('GIVEN user can NOT be messaged, EXPECT resultEmbed to contain "DM Sent = false"', async () => { + let embeds: APIEmbed[] | undefined; + + const command = new Timeout(); + + const interactionReply = jest.fn((options: InteractionReplyOptions) => { + embeds = options.embeds as APIEmbed[]; + }); + + let savedAudit: DeepPartial | undefined; + + const getSetting = jest.spyOn(SettingsHelper, 'GetSetting').mockResolvedValue('mod-logs'); + const auditSave = jest.spyOn(Audit.prototype, 'Save').mockImplementation((target: EntityTarget, entity: DeepPartial): Promise => { + savedAudit = entity; + + return Promise.resolve(); + }); + + const timeoutFunc = jest.fn(); + + let dmChannelSentEmbeds: (APIEmbed | JSONEncodable)[] | undefined; + let logsChannelSentEmbeds: (APIEmbed | JSONEncodable)[] | undefined; + + const dmChannel = { + send: jest.fn().mockImplementation((options: MessageCreateOptions) => { + dmChannelSentEmbeds = options.embeds; + }), + } as unknown as DMChannel; + + const userInput = { + user: { + id: 'userId', + tag: 'userTag', + createDM: jest.fn().mockRejectedValue(undefined), + } as unknown as User, + member: { + manageable: true, + timeout: timeoutFunc, + } as unknown as GuildMember, + } as CommandInteractionOption; + + const lengthInput = { + value: '1s', + } as CommandInteractionOption; + + const reasonInput = { + value: 'Test reason', + } as CommandInteractionOption; + + const logsChannel = { + name: 'mod-logs', + send: jest.fn().mockImplementation((options: MessageCreateOptions) => { + logsChannelSentEmbeds = options.embeds; + }), + } as unknown as TextChannel; + + const interaction = { + guild: { + channels: { + cache: { + find: jest.fn() + .mockReturnValue(logsChannel), + } + }, + name: "Test Guild", + } as unknown as Guild, + guildId: 'guildId', + reply: interactionReply, + options: { + get: jest.fn() + .mockReturnValueOnce(userInput) + .mockReturnValueOnce(lengthInput) + .mockReturnValue(reasonInput), + }, + user: { + id: 'moderatorId' + } + } as unknown as CommandInteraction; + + await command.execute(interaction); + + // EXPECT embeds to be sent + expect(embeds).toBeDefined(); + expect(embeds!.length).toBe(1); + + const resultEmbed = embeds![0] as EmbedBuilder; + + // EXPECT DM field to be configured + const resultEmbedDMField = resultEmbed.data.fields![0]; + + expect(resultEmbedDMField.name).toBe("DM Sent"); + expect(resultEmbedDMField.value).toBe("false"); + }); +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 617ce5d..54ec5c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -124,7 +124,7 @@ dependencies: "@babel/types" "^7.18.6" -"@babel/helper-string-parser@^7.21.5": +"@babel/helper-string-parser@^7.19.4", "@babel/helper-string-parser@^7.21.5": version "7.21.5" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz#2b3eea65443c6bdc31c22d037c65f6d323b6b2bd" integrity sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w== @@ -157,11 +157,16 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.5", "@babel/parser@^7.21.8": +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.21.5", "@babel/parser@^7.21.8": version "7.21.8" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.8.tgz#642af7d0333eab9c0ad70b14ac5e76dbde7bfdf8" integrity sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA== +"@babel/parser@^7.20.7": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.3.tgz#1d285d67a19162ff9daa358d4cb41d50c06220b3" + integrity sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ== + "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -292,7 +297,7 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.4", "@babel/types@^7.21.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3": +"@babel/types@^7.0.0", "@babel/types@^7.21.4", "@babel/types@^7.21.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3": version "7.21.5" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.5.tgz#18dfbd47c39d3904d5db3d3dc2cc80bedb60e5b6" integrity sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q== @@ -301,6 +306,15 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.21.0": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.3.tgz#4865a5357ce40f64e3400b0f3b737dc6d4f64d05" + integrity sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg== + dependencies: + "@babel/helper-string-parser" "^7.19.4" + "@babel/helper-validator-identifier" "^7.19.1" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1023,9 +1037,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001449: - version "1.0.30001486" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001486.tgz#56a08885228edf62cbe1ac8980f2b5dae159997e" - integrity sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg== + version "1.0.30001469" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001469.tgz#3dd505430c8522fdc9f94b4a19518e330f5c945a" + integrity sha512-Rcp7221ScNqQPP3W+lVOYDyjdR6dC+neEQCttoNr5bAyz54AboB4iwpnWgyi8P4YUsPybVzT4LgWiBbI3drL4g== chalk@^2.0.0: version "2.4.2" @@ -2506,7 +2520,7 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -semver@7.x, semver@^7.3.5: +semver@7.x: version "7.5.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0" integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA== @@ -2518,6 +2532,13 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.3.5: + version "7.3.8" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" + integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== + dependencies: + lru-cache "^6.0.0" + sha.js@^2.4.11: version "2.4.11" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7"