diff --git a/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java b/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java index 0b491f9..d6010b2 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java +++ b/src/main/java/me/bobert/netneutrality/core/command/CommandManager.java @@ -85,31 +85,33 @@ public class CommandManager implements CommandRegistry { } public void initCommands() { - Consumer onLoaded = command -> { - if (!command.isEnabled()) { - logger.info("Skipping command {} as it is disabled", command.getClass().getName()); - return; - } - - if (command.getCommandName() == null) { - logger.error("Failed to load command {}: Command name is null", command.getClass().getName()); - return; - } - - if (command.getDescription() == null && command.getType() == Command.Type.CHAT) { - logger.error("Failed to load command {}: Command description is null", command.getClass().getName()); - return; - } - - CommandContainer container = new CommandContainer(command); - if (!commands.containsKey(command.getCommandName())) { - commands.put(command.getCommandName(), container); - } - }; + Consumer onLoaded = this::onCommandLoaded; ReflectionHelper.loadCommands(Command.class, context, onLoaded); } + void onCommandLoaded(Command command) { + if (!command.isEnabled()) { + logger.info("Skipping command {} as it is disabled", command.getClass().getName()); + return; + } + + if (command.getCommandName() == null) { + logger.error("Failed to load command {}: Command name is null", command.getClass().getName()); + return; + } + + if (command.getDescription() == null && command.getType() == Command.Type.CHAT) { + logger.error("Failed to load command {}: Command description is null", command.getClass().getName()); + return; + } + + CommandContainer container = new CommandContainer(command); + if (!commands.containsKey(command.getCommandName())) { + commands.put(command.getCommandName(), container); + } + } + @Override public void registerSlashCommands(JDA jda) { CommandListUpdateAction commandListUpdateAction = jda.updateCommands(); diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandContainerTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandContainerTest.java new file mode 100644 index 0000000..7ed2da1 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/CommandContainerTest.java @@ -0,0 +1,199 @@ +package me.bobert.netneutrality.core.command; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CommandContainerTest { + + @Mock + private Command command; + + @BeforeEach + void setup() { + when(command.getCommandName()).thenReturn("test"); + when(command.getDescription()).thenReturn("A test command"); + when(command.getType()).thenReturn(Command.Type.CHAT); + } + + private CommandContainer container(Consumer registration) { + doAnswer(invocation -> { + registration.accept(invocation.getArgument(0)); + return null; + }).when(command).registerCommandOptions(org.mockito.ArgumentMatchers.any()); + + return new CommandContainer(command); + } + + // ===== NULL_COMMAND and construction ===== + + @Test + void nullCommandShouldHaveNoCommand() { + assertNull(CommandContainer.NULL_COMMAND.getCommand()); + } + + @Test + void shouldExposeTheCommand() { + assertSame(command, container(_ -> { }).getCommand()); + } + + @Test + void shouldExposeTheBuiltSlashData() { + CommandContainer container = container(_ -> { }); + + assertNotNull(container.getSlashCommandData()); + assertEquals("test", container.getSlashCommandData().getName()); + } + + @Test + void shouldExposeTheRegisteredOptionsSubcommandsAndGroups() { + CommandContainer container = container(builder -> { + builder.addOptions(new OptionData(OptionType.STRING, "text", "Text")); + }); + + assertEquals(1, container.getOptions().size()); + assertTrue(container.getSubcommands().isEmpty()); + assertTrue(container.getSubcommandGroups().isEmpty()); + } + + // ===== isEmpty ===== + + @Test + void shouldBeEmptyWithoutAnyOptions() { + assertTrue(container(_ -> { }).isEmpty()); + } + + @Test + void shouldNotBeEmptyWithOptions() { + assertFalse(container(builder -> builder.addOptions(new OptionData(OptionType.STRING, "text", "Text"))).isEmpty()); + } + + @Test + void shouldNotBeEmptyWithSubcommands() { + assertFalse(container(builder -> builder.addSubcommands(new SubcommandData("add", "Adds"))).isEmpty()); + } + + @Test + void shouldNotBeEmptyWithSubcommandGroups() { + CommandContainer container = container(builder -> builder.addSubcommandGroups( + new SubcommandGroupData("grp", "Group").addSubcommands(new SubcommandData("add", "Adds")))); + + assertFalse(container.isEmpty()); + } + + // ===== printUsage ===== + + @Test + void shouldPrintNothingWithoutOptions() { + assertEquals("", container(_ -> { }).printUsage()); + } + + @Test + void shouldPrintPlainOptions() { + CommandContainer container = container(builder -> builder.addOptions( + new OptionData(OptionType.INTEGER, "amount", "Amount", true), + new OptionData(OptionType.STRING, "reason", "Reason"))); + + assertEquals("- Usage: /test \n" + + " - *amount: Amount\n" + + " - reason: Reason\n\n" + + "-# *: required argument", container.printUsage()); + } + + @Test + void shouldPrintMinAndMaxValues() { + CommandContainer container = container(builder -> builder.addOptions( + new OptionData(OptionType.INTEGER, "amount", "Amount").setMinValue(1).setMaxValue(10))); + + String usage = container.printUsage(); + + assertTrue(usage.contains("amount: Amount [Min value: 1] [Max value: 10]"), usage); + } + + @Test + void shouldPrintMinAndMaxLengths() { + CommandContainer container = container(builder -> builder.addOptions( + new OptionData(OptionType.STRING, "text", "Text").setMinLength(2).setMaxLength(20))); + + String usage = container.printUsage(); + + assertTrue(usage.contains("text: Text [Min length: 2] [Max length: 20]"), usage); + } + + @Test + void shouldPrintChoices() { + CommandContainer container = container(builder -> builder.addOptions( + new OptionData(OptionType.STRING, "mode", "Mode").addChoice("One", "one").addChoice("Two", "two"))); + + String usage = container.printUsage(); + + assertTrue(usage.contains("[Available choices: one, two]"), usage); + } + + @Test + void shouldPrintRequiredChannelTypes() { + CommandContainer container = container(builder -> builder.addOptions( + new OptionData(OptionType.CHANNEL, "channel", "Channel").setChannelTypes(ChannelType.TEXT))); + + String usage = container.printUsage(); + + assertTrue(usage.contains("[Required channel types: TEXT]"), usage); + } + + @Test + void shouldPrintSubcommands() { + CommandContainer container = container(builder -> builder.addSubcommands( + new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true)))); + + String usage = container.printUsage(); + + assertTrue(usage.contains("- Usage: /test add (Adds)\n - *name: Name"), usage); + assertTrue(usage.endsWith("-# *: required argument"), usage); + } + + @Test + void shouldPrintSubcommandGroups() { + CommandContainer container = container(builder -> builder.addSubcommandGroups( + new SubcommandGroupData("grp", "Group").addSubcommands( + new SubcommandData("add", "Adds").addOptions(new OptionData(OptionType.STRING, "name", "Name", true))))); + + String usage = container.printUsage(); + + assertTrue(usage.contains("- Subcommand: /test grp (Group)\n"), usage); + assertTrue(usage.contains(" - Usage: /test grp add (Adds)\n - *name: Name"), usage); + assertTrue(usage.endsWith("-# *: required argument"), usage); + } + + @Test + void shouldNotPrintTheRequiredLegendForUsagelessCommands() { + assertFalse(container(_ -> { }).printUsage().contains("required argument")); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandDispatchIntegrationTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandDispatchIntegrationTest.java new file mode 100644 index 0000000..e0fe779 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/CommandDispatchIntegrationTest.java @@ -0,0 +1,138 @@ +package me.bobert.netneutrality.core.command; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.core.command.precondition.DisabledCommandPrecondition; +import me.bobert.netneutrality.utils.UserFunction; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Dispatches through a real {@link CommandProcessor} with the default preconditions, usage validator and + * result handler, so only the Discord entities, the registry and the command itself are mocked. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CommandDispatchIntegrationTest { + private static final String COMMAND_NAME = "test"; + + @Mock + private CommandContext context; + + @Mock + private CommandRegistry commands; + + @Mock + private TaskManager taskManager; + + @Mock + private Command command; + + @Mock + private Guild guild; + + @Mock + private Member member; + + @Mock + private User user; + + @Mock + private MessageChannelUnion channel; + + private CommandProcessor processor; + + @BeforeEach + void setup() { + UserFunction.bind((GuildServiceProvider) null); + DisabledCommandPrecondition.bind(null); + + when(command.getCommandName()).thenReturn(COMMAND_NAME); + when(command.getDescription()).thenReturn("A test command"); + when(command.getType()).thenReturn(Command.Type.CHAT); + when(command.isEnabled()).thenReturn(true); + when(command.canBeExecutedFrom(channel)).thenReturn(true); + when(command.canBeExecutedBy(member)).thenReturn(true); + when(command.execute(context)).thenReturn(CommandExecutionResult.success("Executed")); + + CommandContainer container = new CommandContainer(command); + when(commands.hasCommand(COMMAND_NAME)).thenReturn(true); + when(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container); + + when(context.parseCommandName()).thenReturn(COMMAND_NAME); + when(context.getFullCommand()).thenReturn(COMMAND_NAME); + when(context.getId()).thenReturn("context-id"); + when(context.getGuild()).thenReturn(guild); + when(context.isFromGuild()).thenReturn(true); + when(context.getGuildUser()).thenReturn(member); + when(context.getDiscordUser()).thenReturn(user); + when(context.getChannel()).thenReturn(channel); + when(context.isValid()).thenReturn(true); + when(guild.getId()).thenReturn("111"); + when(member.getId()).thenReturn("222"); + when(user.getId()).thenReturn("222"); + when(channel.getId()).thenReturn("333"); + + processor = new CommandProcessor(context, commands, taskManager); + } + + @AfterEach + void tearDown() { + UserFunction.bind((GuildServiceProvider) null); + DisabledCommandPrecondition.bind(null); + } + + @Test + void shouldBlockANonAdminOnAnAdminCommand() { + when(command.needAdmin()).thenReturn(true); + + processor.dispatch(); + + verify(context).replyEphemeral("You are not powerful enough, scrub"); + verify(command, never()).execute(any()); + verify(context, never()).reply("Executed"); + } + + @Test + void shouldRunAnAllowedCommandToCompletion() { + processor.dispatch(); + + verify(context).setCommand(command); + verify(command).execute(context); + verify(context).reply("Executed"); + } + + @Test + void shouldBlockAGuildOnlyCommandInPrivateMessages() { + when(command.isGuildOnly()).thenReturn(true); + when(context.isFromGuild()).thenReturn(false); + + processor.dispatch(); + + verify(context).replyEphemeral("This command is only available in a guild"); + verify(context).deleteMessage(); + verify(command, never()).execute(any()); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandManagerTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandManagerTest.java new file mode 100644 index 0000000..aa5e5a4 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/CommandManagerTest.java @@ -0,0 +1,246 @@ +package me.bobert.netneutrality.core.command; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.bootstrap.ApplicationContext; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.interactions.InteractionContextType; +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.build.CommandData; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.RETURNS_SELF; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CommandManagerTest { + + @Mock + private ApplicationContext applicationContext; + + @Mock + private JDA jda; + + private CommandListUpdateAction updateAction; + + private CommandManager manager; + + @BeforeEach + void setup() { + manager = new CommandManager(applicationContext); + + updateAction = mock(CommandListUpdateAction.class, RETURNS_SELF); + when(jda.updateCommands()).thenReturn(updateAction); + } + + private Command command(String name, String description) { + Command command = mock(Command.class); + when(command.getCommandName()).thenReturn(name); + when(command.getDescription()).thenReturn(description); + when(command.getType()).thenReturn(Command.Type.CHAT); + when(command.isEnabled()).thenReturn(true); + when(command.isSlashCommand()).thenReturn(true); + when(command.isGuildOnly()).thenReturn(false); + when(command.getSlashCommandPermission()).thenReturn(DefaultMemberPermissions.ENABLED); + return command; + } + + @SuppressWarnings("unchecked") + private List registeredCommands() { + manager.registerSlashCommands(jda); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(updateAction).addCommands(captor.capture()); + verify(updateAction).queue(); + return List.copyOf(captor.getValue()); + } + + // ===== Construction ===== + + @Test + void shouldRequireTheApplicationContext() { + assertThrows(NullPointerException.class, () -> new CommandManager(null)); + } + + @Test + void shouldStartAfterTheOtherRegistries() { + assertEquals(30, manager.priority()); + } + + // ===== Loading ===== + + @Test + void shouldRegisterAnEnabledCommand() { + Command ping = command("ping", "Pings"); + + manager.onCommandLoaded(ping); + + assertTrue(manager.hasCommand("ping")); + assertSame(ping, manager.getCommandContainer("ping").getCommand()); + } + + @Test + void shouldSkipDisabledCommands() { + Command ping = command("ping", "Pings"); + when(ping.isEnabled()).thenReturn(false); + + manager.onCommandLoaded(ping); + + assertFalse(manager.hasCommand("ping")); + } + + @Test + void shouldSkipCommandsWithoutAName() { + manager.onCommandLoaded(command(null, "Pings")); + + assertTrue(manager.getCommands().isEmpty()); + } + + @Test + void shouldSkipChatCommandsWithoutADescription() { + manager.onCommandLoaded(command("ping", null)); + + assertFalse(manager.hasCommand("ping")); + } + + @Test + void shouldKeepMessageCommandsWithoutADescription() { + Command menu = command("menu", null); + when(menu.getType()).thenReturn(Command.Type.MESSAGE); + + manager.onCommandLoaded(menu); + + assertTrue(manager.hasCommand("menu")); + } + + @Test + void shouldKeepTheFirstCommandWhenNamesAreDuplicated() { + Command first = command("ping", "First"); + Command second = command("ping", "Second"); + + manager.onCommandLoaded(first); + manager.onCommandLoaded(second); + + assertEquals(1, manager.getCommands().size()); + assertSame(first, manager.getCommandContainer("ping").getCommand()); + } + + // ===== Lookups ===== + + @Test + void shouldNotHaveAnUnknownCommand() { + assertFalse(manager.hasCommand("unknown")); + } + + @Test + void shouldReturnTheNullCommandForAnUnknownName() { + assertSame(CommandContainer.NULL_COMMAND, manager.getCommandContainer("unknown")); + } + + // ===== Slash command registration ===== + + @Test + void shouldRegisterEnabledSlashCommands() { + manager.onCommandLoaded(command("ping", "Pings")); + + List registered = registeredCommands(); + + assertEquals(1, registered.size()); + assertEquals("ping", registered.getFirst().getName()); + } + + @Test + void shouldRegisterNothingWithoutCommands() { + assertTrue(registeredCommands().isEmpty()); + } + + @Test + void shouldNotRegisterCommandsThatAreNotSlashCommands() { + Command ping = command("ping", "Pings"); + manager.onCommandLoaded(ping); + when(ping.isSlashCommand()).thenReturn(false); + + assertTrue(registeredCommands().isEmpty()); + } + + @Test + void shouldNotRegisterCommandsDisabledAfterLoading() { + Command ping = command("ping", "Pings"); + manager.onCommandLoaded(ping); + when(ping.isEnabled()).thenReturn(false); + + assertTrue(registeredCommands().isEmpty()); + } + + @Test + void shouldRestrictGuildOnlyCommandsToGuilds() { + Command ping = command("ping", "Pings"); + when(ping.isGuildOnly()).thenReturn(true); + manager.onCommandLoaded(ping); + + SlashCommandData data = (SlashCommandData) registeredCommands().getFirst(); + + assertEquals(Set.of(InteractionContextType.GUILD), data.getContexts()); + } + + @Test + void shouldAllowOtherCommandsInGuildsAndBotDms() { + manager.onCommandLoaded(command("ping", "Pings")); + + SlashCommandData data = (SlashCommandData) registeredCommands().getFirst(); + + assertEquals(Set.of(InteractionContextType.GUILD, InteractionContextType.BOT_DM), data.getContexts()); + } + + @Test + void shouldApplyTheDefaultPermissionOfTheCommand() { + Command ping = command("ping", "Pings"); + when(ping.getSlashCommandPermission()).thenReturn(DefaultMemberPermissions.DISABLED); + manager.onCommandLoaded(ping); + + SlashCommandData data = (SlashCommandData) registeredCommands().getFirst(); + + assertEquals(DefaultMemberPermissions.DISABLED, data.getDefaultPermissions()); + } + + @Test + void shouldKeepRegisteringWhenOneCommandFails() { + Command broken = command("broken", "Broken"); + CommandContainer brokenContainer = mock(CommandContainer.class); + when(brokenContainer.getCommand()).thenReturn(broken); + when(brokenContainer.getSlashCommandData()).thenThrow(new IllegalStateException("boom")); + manager.getCommands().put("broken", brokenContainer); + manager.onCommandLoaded(command("ping", "Pings")); + + List registered = registeredCommands(); + + assertEquals(1, registered.size()); + assertEquals("ping", registered.getFirst().getName()); + verify(brokenContainer).getSlashCommandData(); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandOptionsBuilderTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandOptionsBuilderTest.java new file mode 100644 index 0000000..263d95a --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/CommandOptionsBuilderTest.java @@ -0,0 +1,105 @@ +package me.bobert.netneutrality.core.command; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CommandOptionsBuilderTest { + + @Mock + private Command command; + + @BeforeEach + void setup() { + when(command.getCommandName()).thenReturn("test"); + when(command.getDescription()).thenReturn("A test command"); + } + + @Test + void shouldBuildSlashDataForChatCommands() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command); + + assertNotNull(builder.build()); + assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.SLASH, builder.getType()); + assertEquals("test", builder.getName()); + assertEquals("A test command", builder.getDescription()); + } + + @Test + void shouldBuildUserContextMenuDataForUserCommands() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.USER, command); + + assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.USER, builder.build().getType()); + assertEquals("test", builder.build().getName()); + } + + @Test + void shouldBuildMessageContextMenuDataForMessageCommands() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.MESSAGE, command); + + assertEquals(net.dv8tion.jda.api.interactions.commands.Command.Type.MESSAGE, builder.build().getType()); + assertEquals("test", builder.build().getName()); + } + + @Test + void shouldStartWithoutOptions() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command); + + assertTrue(builder.getOptions().isEmpty()); + assertTrue(builder.getSubcommands().isEmpty()); + assertTrue(builder.getSubcommandGroups().isEmpty()); + } + + @Test + void shouldDelegateAddedOptions() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command); + + builder.addOptions(new OptionData(OptionType.STRING, "text", "Some text", true)); + + assertEquals(1, builder.build().getOptions().size()); + assertEquals("text", builder.getOptions().getFirst().getName()); + } + + @Test + void shouldDelegateAddedSubcommandsAndGroups() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command); + + builder.addSubcommands(new SubcommandData("add", "Adds")); + builder.addSubcommandGroups(new SubcommandGroupData("grp", "Group").addSubcommands(new SubcommandData("sub", "Sub"))); + + assertEquals("add", builder.getSubcommands().getFirst().getName()); + assertEquals("grp", builder.getSubcommandGroups().getFirst().getName()); + } + + @Test + void shouldDelegateSettersToTheBuiltData() { + CommandOptionsBuilder builder = new CommandOptionsBuilder(Command.Type.CHAT, command); + + builder.setNSFW(true); + builder.setDescription("Changed"); + + assertTrue(builder.build().isNSFW()); + assertEquals("Changed", builder.build().getDescription()); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandResultHandlerTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandResultHandlerTest.java new file mode 100644 index 0000000..06ce8d8 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/CommandResultHandlerTest.java @@ -0,0 +1,113 @@ +package me.bobert.netneutrality.core.command; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.core.tasks.Task; +import me.bobert.netneutrality.core.tasks.TaskManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CommandResultHandlerTest { + + @Mock + private CommandContext context; + + @Mock + private TaskManager taskManager; + + @Mock + private Task task; + + private final CommandResultHandler handler = new CommandResultHandler(); + + @Test + void shouldDoNothingForNoAction() { + handler.handle(context, CommandExecutionResult.none(), taskManager); + + verify(context, never()).reply(anyString()); + verifyNoInteractions(taskManager); + } + + @Test + void shouldInsertTheTaskForATaskResult() { + handler.handle(context, CommandExecutionResult.task(task), taskManager); + + verify(taskManager).insert(context, task); + } + + @Test + void shouldNotReplyForATaskResultWithoutMessage() { + handler.handle(context, CommandExecutionResult.task(task), taskManager); + + verify(context, never()).reply(anyString()); + } + + @Test + void shouldReplyForASuccess() { + handler.handle(context, CommandExecutionResult.success("Done"), taskManager); + + verify(context).reply("Done"); + verify(taskManager, never()).insert(any(), any()); + } + + @Test + void shouldReplyForAnInfo() { + handler.handle(context, CommandExecutionResult.info("FYI"), taskManager); + + verify(context).reply("FYI"); + } + + @Test + void shouldReplyForAWarning() { + handler.handle(context, CommandExecutionResult.warn("Careful"), taskManager); + + verify(context).reply("Careful"); + } + + @Test + void shouldReplyForAnError() { + handler.handle(context, CommandExecutionResult.error("Broken", new IllegalStateException("boom")), taskManager); + + verify(context).reply("Broken"); + } + + @Test + void shouldReplyForAnErrorWithoutException() { + handler.handle(context, CommandExecutionResult.error("Broken"), taskManager); + + verify(context).reply("Broken"); + } + + @Test + void shouldNotReplyWhenTheMessageIsNull() { + handler.handle(context, CommandExecutionResult.success((String) null), taskManager); + + verify(context, never()).reply(anyString()); + } + + @Test + void shouldFailForANullResult() { + when(context.getId()).thenReturn("context-id"); + + assertThrows(RuntimeException.class, () -> handler.handle(context, null, taskManager)); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPreconditionTest.java b/src/test/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPreconditionTest.java new file mode 100644 index 0000000..0c08ea1 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/precondition/DisabledCommandPreconditionTest.java @@ -0,0 +1,118 @@ +package me.bobert.netneutrality.core.command.precondition; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.Command; +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.db.service.GuildService; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.db.service.GuildSettingService; +import net.dv8tion.jda.api.entities.Guild; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DisabledCommandPreconditionTest { + + @Mock + private CommandContext context; + + @Mock + private Command command; + + @Mock + private Guild guild; + + @Mock + private GuildServiceProvider provider; + + @Mock + private GuildService guildService; + + @Mock + private GuildSettingService settingService; + + private final DisabledCommandPrecondition precondition = new DisabledCommandPrecondition(); + + @BeforeEach + void setup() { + when(context.getGuild()).thenReturn(guild); + when(guild.getId()).thenReturn("111"); + when(command.getCommandName()).thenReturn("ping"); + when(provider.get("111")).thenReturn(guildService); + when(guildService.getGuildSettingService()).thenReturn(settingService); + when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn(""); + + DisabledCommandPrecondition.bind(provider); + } + + @AfterEach + void tearDown() { + DisabledCommandPrecondition.bind(null); + } + + @Test + void shouldPassWhenNoProviderIsBound() { + DisabledCommandPrecondition.bind(null); + + assertTrue(precondition.check(context, command)); + } + + @Test + void shouldPassOutsideAGuild() { + when(context.getGuild()).thenReturn(null); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } + + @Test + void shouldPassWhenNoCommandIsDisabled() { + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } + + @Test + void shouldPassWhenAnotherCommandIsDisabled() { + when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play,skip"); + + assertTrue(precondition.check(context, command)); + } + + @Test + void shouldBlockADisabledCommand() { + when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play,ping"); + + assertFalse(precondition.check(context, command)); + + verify(context).replyEphemeral("This command is disabled on this server"); + } + + @Test + void shouldIgnoreCaseAndWhitespaceWhenMatching() { + when(settingService.getOrDefault(GSettings.DISABLED_COMMANDS)).thenReturn("play, PING "); + + assertFalse(precondition.check(context, command)); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/precondition/GuildOnlyPreconditionTest.java b/src/test/java/me/bobert/netneutrality/core/command/precondition/GuildOnlyPreconditionTest.java new file mode 100644 index 0000000..c34b465 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/precondition/GuildOnlyPreconditionTest.java @@ -0,0 +1,68 @@ +package me.bobert.netneutrality.core.command.precondition; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.Command; +import me.bobert.netneutrality.core.context.CommandContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class GuildOnlyPreconditionTest { + + @Mock + private CommandContext context; + + @Mock + private Command command; + + private final GuildOnlyPrecondition precondition = new GuildOnlyPrecondition(); + + @Test + void shouldPassGuildOnlyCommandInAGuild() { + when(command.isGuildOnly()).thenReturn(true); + when(context.isFromGuild()).thenReturn(true); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(org.mockito.ArgumentMatchers.anyString()); + verify(context, never()).deleteMessage(); + } + + @Test + void shouldBlockGuildOnlyCommandOutsideAGuild() { + when(command.isGuildOnly()).thenReturn(true); + when(context.isFromGuild()).thenReturn(false); + + assertFalse(precondition.check(context, command)); + + verify(context).replyEphemeral("This command is only available in a guild"); + verify(context).deleteMessage(); + } + + @Test + void shouldPassCommandThatIsNotGuildOnlyOutsideAGuild() { + when(command.isGuildOnly()).thenReturn(false); + when(context.isFromGuild()).thenReturn(false); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(org.mockito.ArgumentMatchers.anyString()); + verify(context, never()).deleteMessage(); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/precondition/PermissionPreconditionTest.java b/src/test/java/me/bobert/netneutrality/core/command/precondition/PermissionPreconditionTest.java new file mode 100644 index 0000000..f4218bc --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/precondition/PermissionPreconditionTest.java @@ -0,0 +1,142 @@ +package me.bobert.netneutrality.core.command.precondition; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.Command; +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.utils.UserFunction; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PermissionPreconditionTest { + private static final String DENIED = "You are not powerful enough, scrub"; + + @Mock + private CommandContext context; + + @Mock + private Command command; + + @Mock + private Member member; + + @Mock + private Role role; + + @Mock + private MessageChannelUnion channel; + + private final PermissionPrecondition precondition = new PermissionPrecondition(); + + @BeforeEach + void setup() { + UserFunction.bind(null); + + when(command.getType()).thenReturn(Command.Type.CHAT); + when(context.canSkipPermissionCheck()).thenReturn(false); + when(context.getChannel()).thenReturn(channel); + when(context.getGuildUser()).thenReturn(member); + when(command.canBeExecutedFrom(channel)).thenReturn(true); + when(command.needAdmin()).thenReturn(false); + when(command.canBeExecutedBy(member)).thenReturn(true); + when(member.getRoles()).thenReturn(List.of()); + } + + @AfterEach + void tearDown() { + UserFunction.bind(null); + } + + @Test + void shouldSkipTheCheckForMessageCommands() { + when(command.getType()).thenReturn(Command.Type.MESSAGE); + when(command.canBeExecutedFrom(channel)).thenReturn(false); + when(command.canBeExecutedBy(member)).thenReturn(false); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } + + @Test + void shouldPassWhenThePermissionCheckCanBeSkipped() { + when(context.canSkipPermissionCheck()).thenReturn(true); + when(command.canBeExecutedFrom(channel)).thenReturn(false); + when(command.canBeExecutedBy(member)).thenReturn(false); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } + + @Test + void shouldBlockWhenTheCommandCannotRunInTheChannel() { + when(command.canBeExecutedFrom(channel)).thenReturn(false); + + assertFalse(precondition.check(context, command)); + + verify(context).replyEphemeral("This command cannot be executed in the current channel"); + } + + @Test + void shouldBlockAnAdminCommandForANonAdmin() { + when(command.needAdmin()).thenReturn(true); + + assertFalse(precondition.check(context, command)); + + verify(context).replyEphemeral(DENIED); + } + + @Test + void shouldPassAnAdminCommandForAnAdmin() { + when(command.needAdmin()).thenReturn(true); + when(role.getPermissions()).thenReturn(EnumSet.of(Permission.ADMINISTRATOR)); + when(member.getRoles()).thenReturn(List.of(role)); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } + + @Test + void shouldBlockWhenTheMemberCannotExecuteTheCommand() { + when(command.canBeExecutedBy(member)).thenReturn(false); + + assertFalse(precondition.check(context, command)); + + verify(context).replyEphemeral(DENIED); + } + + @Test + void shouldPassWhenEveryCheckSucceeds() { + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/precondition/SlashCommandOnlyPreconditionTest.java b/src/test/java/me/bobert/netneutrality/core/command/precondition/SlashCommandOnlyPreconditionTest.java new file mode 100644 index 0000000..101c63d --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/precondition/SlashCommandOnlyPreconditionTest.java @@ -0,0 +1,66 @@ +package me.bobert.netneutrality.core.command.precondition; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.Command; +import me.bobert.netneutrality.core.context.CommandContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SlashCommandOnlyPreconditionTest { + + @Mock + private CommandContext context; + + @Mock + private Command command; + + private final SlashCommandOnlyPrecondition precondition = new SlashCommandOnlyPrecondition(); + + @Test + void shouldPassSlashOnlyCommandFromAnInteraction() { + when(command.isSlashCommandOnly()).thenReturn(true); + when(context.isInteraction()).thenReturn(true); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } + + @Test + void shouldBlockSlashOnlyCommandSentAsAMessage() { + when(command.isSlashCommandOnly()).thenReturn(true); + when(context.isInteraction()).thenReturn(false); + + assertFalse(precondition.check(context, command)); + + verify(context).replyEphemeral("This command is only available as a slash command"); + } + + @Test + void shouldPassCommandThatIsNotSlashOnlyAsAMessage() { + when(command.isSlashCommandOnly()).thenReturn(false); + when(context.isInteraction()).thenReturn(false); + + assertTrue(precondition.check(context, command)); + + verify(context, never()).replyEphemeral(anyString()); + } +}