diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java index 664bd69306..ef1128f035 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java @@ -1,5 +1,6 @@ package org.togetherjava.tjbot.commands.reminder; +import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.ISnowflake; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -25,7 +26,7 @@ * *
  * {@code
- * /remind amount: 5 unit: weeks content: Hello World!
+ * /remind time-amount: 5 time-unit: weeks content: Hello World!
  * }
  * 
*

@@ -33,16 +34,16 @@ */ public final class RemindCommand extends SlashCommandAdapter { private static final String COMMAND_NAME = "remind"; - private static final String TIME_AMOUNT_OPTION = "time-amount"; - private static final String TIME_UNIT_OPTION = "time-unit"; - private static final String CONTENT_OPTION = "content"; + static final String TIME_AMOUNT_OPTION = "time-amount"; + static final String TIME_UNIT_OPTION = "time-unit"; + static final String CONTENT_OPTION = "content"; private static final int MIN_TIME_AMOUNT = 1; private static final int MAX_TIME_AMOUNT = 1_000; private static final List TIME_UNITS = List.of("minutes", "hours", "days", "weeks", "months", "years"); private static final Period MAX_TIME_PERIOD = Period.ofYears(3); - private static final int MAX_PENDING_REMINDERS_PER_USER = 100; + static final int MAX_PENDING_REMINDERS_PER_USER = 100; private final Database database; @@ -78,11 +79,12 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { Instant remindAt = parseWhen(timeAmount, timeUnit); User author = event.getUser(); + Guild guild = event.getGuild(); if (!handleIsRemindAtWithinLimits(remindAt, event)) { return; } - if (!handleIsUserBelowMaxPendingReminders(author, event)) { + if (!handleIsUserBelowMaxPendingReminders(author, guild, event)) { return; } @@ -92,7 +94,7 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { database.write(context -> context.newRecord(PENDING_REMINDERS) .setCreatedAt(Instant.now()) - .setGuildId(event.getGuild().getIdLong()) + .setGuildId(guild.getIdLong()) .setChannelId(event.getChannel().getIdLong()) .setAuthorId(author.getIdLong()) .setRemindAt(remindAt) @@ -133,9 +135,10 @@ private static boolean handleIsRemindAtWithinLimits(@NotNull Instant remindAt, } private boolean handleIsUserBelowMaxPendingReminders(@NotNull ISnowflake author, - @NotNull IReplyCallback event) { + @NotNull ISnowflake guild, @NotNull IReplyCallback event) { int pendingReminders = database.read(context -> context.fetchCount(PENDING_REMINDERS, - PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()))); + PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()) + .and(PENDING_REMINDERS.GUILD_ID.equal(guild.getIdLong())))); if (pendingReminders < MAX_PENDING_REMINDERS_PER_USER) { return true; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java index e232ae1519..563231afb3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java @@ -27,7 +27,7 @@ * Reminders can be set by using {@link RemindCommand}. */ public final class RemindRoutine implements Routine { - private static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); + static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); private static final Color AMBIENT_COLOR = Color.decode("#F7F492"); private static final int SCHEDULE_INTERVAL_SECONDS = 30; private final Database database; diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java new file mode 100644 index 0000000000..14125f5b76 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java @@ -0,0 +1,67 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.TextChannel; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.Tables; +import org.togetherjava.tjbot.db.generated.tables.records.PendingRemindersRecord; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.time.Instant; +import java.util.List; + +import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS; + +final class RawReminderTestHelper { + private Database database; + private JdaTester jdaTester; + + RawReminderTestHelper(@NotNull Database database, @NotNull JdaTester jdaTester) { + this.database = database; + this.jdaTester = jdaTester; + } + + void insertReminder(@NotNull String content, @NotNull Instant remindAt) { + insertReminder(content, remindAt, jdaTester.getMemberSpy(), jdaTester.getTextChannelSpy()); + } + + void insertReminder(@NotNull String content, @NotNull Instant remindAt, + @NotNull Member author) { + insertReminder(content, remindAt, author, jdaTester.getTextChannelSpy()); + } + + void insertReminder(@NotNull String content, @NotNull Instant remindAt, @NotNull Member author, + @NotNull TextChannel channel) { + long channelId = channel.getIdLong(); + long guildId = channel.getGuild().getIdLong(); + long authorId = author.getIdLong(); + + database.write(context -> context.newRecord(Tables.PENDING_REMINDERS) + .setCreatedAt(Instant.now()) + .setGuildId(guildId) + .setChannelId(channelId) + .setAuthorId(authorId) + .setRemindAt(remindAt) + .setContent(content) + .insert()); + } + + @NotNull + List readReminders() { + return readReminders(jdaTester.getMemberSpy()); + } + + @NotNull + List readReminders(@NotNull Member author) { + long guildId = jdaTester.getTextChannelSpy().getGuild().getIdLong(); + long authorId = author.getIdLong(); + + return database.read(context -> context.selectFrom(PENDING_REMINDERS) + .where(PENDING_REMINDERS.AUTHOR_ID.eq(authorId) + .and(PENDING_REMINDERS.GUILD_ID.eq(guildId))) + .stream() + .map(PendingRemindersRecord::getContent) + .toList()); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java new file mode 100644 index 0000000000..697e96853c --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java @@ -0,0 +1,137 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.togetherjava.tjbot.commands.SlashCommand; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.verify; +import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS; + +final class RemindCommandTest { + private SlashCommand command; + private JdaTester jdaTester; + private RawReminderTestHelper rawReminders; + + @BeforeEach + void setUp() { + Database database = Database.createMemoryDatabase(PENDING_REMINDERS); + command = new RemindCommand(database); + jdaTester = new JdaTester(); + rawReminders = new RawReminderTestHelper(database, jdaTester); + } + + private @NotNull SlashCommandInteractionEvent triggerSlashCommand(int timeAmount, + @NotNull String timeUnit, @NotNull String content) { + return triggerSlashCommand(timeAmount, timeUnit, content, jdaTester.getMemberSpy()); + } + + private @NotNull SlashCommandInteractionEvent triggerSlashCommand(int timeAmount, + @NotNull String timeUnit, @NotNull String content, @NotNull Member author) { + SlashCommandInteractionEvent event = jdaTester.createSlashCommandInteractionEvent(command) + .setOption(RemindCommand.TIME_AMOUNT_OPTION, timeAmount) + .setOption(RemindCommand.TIME_UNIT_OPTION, timeUnit) + .setOption(RemindCommand.CONTENT_OPTION, content) + .setUserWhoTriggered(author) + .build(); + + command.onSlashCommand(event); + return event; + } + + @Test + @DisplayName("Throws an exception if the time unit is not supported, i.e. not part of the actual choice dialog") + void throwsWhenGivenUnsupportedUnit() { + // GIVEN + // WHEN triggering /remind with the unsupported time unit 'nanoseconds' + Executable triggerRemind = () -> triggerSlashCommand(10, "nanoseconds", "foo"); + + // THEN command throws, no reminder was created + Assertions.assertThrows(IllegalArgumentException.class, triggerRemind); + assertTrue(rawReminders.readReminders().isEmpty()); + } + + @Test + @DisplayName("Rejects a reminder time that is set too far in the future and responds accordingly") + void doesNotSupportDatesTooFarInFuture() { + // GIVEN + // WHEN triggering /remind too far in the future + SlashCommandInteractionEvent event = triggerSlashCommand(10, "years", "foo"); + + // THEN rejects and responds accordingly, no reminder was created + verify(event).reply(startsWith("The reminder is set too far in the future")); + assertTrue(rawReminders.readReminders().isEmpty()); + } + + @Test + @DisplayName("Rejects a reminder if a user has too many reminders still pending") + void userIsLimitedIfTooManyPendingReminders() { + // GIVEN a user with too many reminders still pending + Instant remindAt = Instant.now().plus(100, ChronoUnit.DAYS); + for (int i = 0; i < RemindCommand.MAX_PENDING_REMINDERS_PER_USER; i++) { + rawReminders.insertReminder("foo " + i, remindAt); + } + + // WHEN triggering another reminder + SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo"); + + // THEN rejects and responds accordingly, no new reminder was created + verify(event) + .reply(startsWith("You have reached the maximum amount of pending reminders per user")); + assertEquals(RemindCommand.MAX_PENDING_REMINDERS_PER_USER, + rawReminders.readReminders().size()); + } + + @Test + @DisplayName("Does not limit a user if another user has too many reminders still pending, i.e. the limit is per user") + void userIsNotLimitedIfOtherUserHasTooManyPendingReminders() { + // GIVEN a user with too many reminders still pending, + // and a second user with no reminders yet + Member firstUser = jdaTester.createMemberSpy(1); + Instant remindAt = Instant.now().plus(100, ChronoUnit.DAYS); + for (int i = 0; i < RemindCommand.MAX_PENDING_REMINDERS_PER_USER; i++) { + rawReminders.insertReminder("foo " + i, remindAt, firstUser); + } + + Member secondUser = jdaTester.createMemberSpy(2); + + // WHEN the second user triggers another reminder + SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo", secondUser); + + // THEN accepts the reminder and responds accordingly + verify(event).reply("Will remind you about 'foo' in 5 minutes."); + + List remindersOfSecondUser = rawReminders.readReminders(secondUser); + assertEquals(1, remindersOfSecondUser.size()); + assertEquals("foo", remindersOfSecondUser.get(0)); + } + + @Test + @DisplayName("The command can create a reminder, the regular base case") + void canCreateReminders() { + // GIVEN + // WHEN triggering the /remind command + SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo"); + + // THEN accepts the reminder and responds accordingly + verify(event).reply("Will remind you about 'foo' in 5 minutes."); + + List pendingReminders = rawReminders.readReminders(); + assertEquals(1, pendingReminders.size()); + assertEquals("foo", pendingReminders.get(0)); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java new file mode 100644 index 0000000000..01271f94ab --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java @@ -0,0 +1,181 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.requests.RestAction; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.togetherjava.tjbot.commands.Routine; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; +import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS; + +final class RemindRoutineTest { + private Routine routine; + private JdaTester jdaTester; + private RawReminderTestHelper rawReminders; + + @BeforeEach + void setUp() { + Database database = Database.createMemoryDatabase(PENDING_REMINDERS); + routine = new RemindRoutine(database); + jdaTester = new JdaTester(); + rawReminders = new RawReminderTestHelper(database, jdaTester); + } + + private void triggerRoutine() { + routine.runRoutine(jdaTester.getJdaMock()); + } + + private static @NotNull MessageEmbed getLastMessageFrom(@NotNull MessageChannel channel) { + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(channel).sendMessageEmbeds(responseCaptor.capture()); + return responseCaptor.getValue(); + } + + private @NotNull Member createAndSetupUnknownMember() { + int unknownMemberId = 2; + + Member member = jdaTester.createMemberSpy(unknownMemberId); + + RestAction unknownMemberAction = jdaTester.createFailedActionMock( + jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_USER)); + when(jdaTester.getJdaMock().retrieveUserById(unknownMemberId)) + .thenReturn(unknownMemberAction); + + RestAction unknownPrivateChannelAction = jdaTester.createFailedActionMock( + jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_USER)); + when(jdaTester.getJdaMock().openPrivateChannelById(anyLong())) + .thenReturn(unknownPrivateChannelAction); + when(jdaTester.getJdaMock().openPrivateChannelById(anyString())) + .thenReturn(unknownPrivateChannelAction); + + return member; + } + + private @NotNull TextChannel createAndSetupUnknownChannel() { + long unknownChannelId = 2; + + TextChannel channel = jdaTester.createTextChannelSpy(unknownChannelId); + when(jdaTester.getJdaMock() + .getChannelById(ArgumentMatchers.>any(), eq(unknownChannelId))) + .thenReturn(null); + + return channel; + } + + @Test + @DisplayName("Sends out a pending reminder to a guild channel, the base case") + void sendsPendingReminderChannelFoundAuthorFound() { + // GIVEN a pending reminder + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member author = jdaTester.getMemberSpy(); + rawReminders.insertReminder("foo", remindAt, author); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is sent out and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + + MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getTextChannelSpy()); + assertEquals(reminderContent, lastMessage.getDescription()); + assertSimilar(remindAt, lastMessage.getTimestamp().toInstant()); + assertEquals(author.getUser().getAsTag(), lastMessage.getAuthor().getName()); + } + + @Test + @DisplayName("Sends out a pending reminder to a guild channel, even if the author could not be retrieved anymore") + void sendsPendingReminderChannelFoundAuthorNotFound() { + // GIVEN a pending reminder from an unknown user + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member unknownAuthor = createAndSetupUnknownMember(); + rawReminders.insertReminder("foo", remindAt, unknownAuthor); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is sent out and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + + MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getTextChannelSpy()); + assertEquals(reminderContent, lastMessage.getDescription()); + assertSimilar(remindAt, lastMessage.getTimestamp().toInstant()); + assertEquals("Unknown user", lastMessage.getAuthor().getName()); + } + + @Test + @DisplayName("Sends out a pending reminder via DM, even if the channel could not be retrieved anymore") + void sendsPendingReminderChannelNotFoundAuthorFound() { + // GIVEN a pending reminder from an unknown channel + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member author = jdaTester.getMemberSpy(); + TextChannel unknownChannel = createAndSetupUnknownChannel(); + rawReminders.insertReminder("foo", remindAt, author, unknownChannel); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is sent out and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + + MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getPrivateChannelSpy()); + assertEquals(reminderContent, lastMessage.getDescription()); + assertSimilar(remindAt, lastMessage.getTimestamp().toInstant()); + assertEquals(author.getUser().getAsTag(), lastMessage.getAuthor().getName()); + } + + @Test + @DisplayName("Skips a pending reminder if sending it out resulted in an error") + void skipPendingReminderOnErrorChannelNotFoundAuthorNotFound() { + // GIVEN a pending reminder and from an unknown channel and author + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member unknownAuthor = createAndSetupUnknownMember(); + TextChannel unknownChannel = createAndSetupUnknownChannel(); + rawReminders.insertReminder("foo", remindAt, unknownAuthor, unknownChannel); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is skipped and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + } + + @Test + @DisplayName("A reminder that is not pending yet, is not send out") + void reminderIsNotSendIfNotPending() { + // GIVEN a reminder that is not pending yet + Instant remindAt = Instant.now().plus(1, ChronoUnit.HOURS); + String reminderContent = "foo"; + rawReminders.insertReminder("foo", remindAt); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is not send yet and still in the database + assertEquals(1, rawReminders.readReminders().size()); + verify(jdaTester.getTextChannelSpy(), never()).sendMessageEmbeds(any(MessageEmbed.class)); + } + + private static void assertSimilar(@NotNull Instant expected, @NotNull Instant actual) { + // NOTE For some reason, the instant ends up in the database slightly wrong already (about + // half a second), seems to be an issue with jOOQ + assertEquals(expected.toEpochMilli(), actual.toEpochMilli(), TimeUnit.SECONDS.toMillis(1)); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java index 2b07ba9c50..ab2428770e 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -1,6 +1,7 @@ package org.togetherjava.tjbot.jda; import net.dv8tion.jda.api.AccountType; +import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -38,6 +39,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import java.util.function.UnaryOperator; @@ -130,6 +132,9 @@ public JdaTester() { doReturn(APPLICATION_ID).when(selfUser).getApplicationIdLong(); doReturn(selfUser).when(jda).getSelfUser(); when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel); + when(jda.getTextChannelById(anyLong())).thenReturn(textChannel); + when(jda.getChannelById(ArgumentMatchers.>any(), anyLong())) + .thenReturn(textChannel); when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel); when(jda.getGuildById(anyLong())).thenReturn(guild); when(jda.getEntityBuilder()).thenReturn(entityBuilder); @@ -140,8 +145,6 @@ public JdaTester() { doReturn(new Requester(jda, new AuthorizationConfig(TEST_TOKEN))).when(jda).getRequester(); when(jda.getAccountType()).thenReturn(AccountType.BOT); - doReturn(messageAction).when(privateChannel).sendMessage(anyString()); - replyAction = mock(ReplyCallbackActionImpl.class); when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction); when(replyAction.addActionRow(anyCollection())).thenReturn(replyAction); @@ -155,7 +158,6 @@ public JdaTester() { auditableRestAction = (AuditableRestActionImpl) mock(AuditableRestActionImpl.class); doNothing().when(auditableRestAction).queue(); - doNothing().when(messageAction).queue(); doNothing().when(webhookMessageUpdateAction).queue(); doReturn(webhookMessageUpdateAction).when(webhookMessageUpdateAction) .setActionRow(any(ItemComponent.class)); @@ -164,6 +166,9 @@ public JdaTester() { doReturn(selfMember).when(guild).getMember(selfUser); doReturn(member).when(guild).getMember(not(eq(selfUser))); + RestAction userAction = createSucceededActionMock(member.getUser()); + when(jda.retrieveUserById(anyLong())).thenReturn(userAction); + doReturn(null).when(textChannel).retrieveMessageById(any()); interactionHook = mock(InteractionHook.class); @@ -172,6 +177,20 @@ public JdaTester() { .thenReturn(webhookMessageUpdateAction); when(interactionHook.editOriginal(any(byte[].class), any(), any())) .thenReturn(webhookMessageUpdateAction); + + doReturn(messageAction).when(textChannel).sendMessageEmbeds(any(), any()); + doReturn(messageAction).when(textChannel).sendMessageEmbeds(any()); + + doNothing().when(messageAction).queue(); + when(messageAction.content(any())).thenReturn(messageAction); + + RestAction privateChannelAction = createSucceededActionMock(privateChannel); + when(jda.openPrivateChannelById(anyLong())).thenReturn(privateChannelAction); + when(jda.openPrivateChannelById(anyString())).thenReturn(privateChannelAction); + doReturn(null).when(privateChannel).retrieveMessageById(any()); + doReturn(messageAction).when(privateChannel).sendMessage(anyString()); + doReturn(messageAction).when(privateChannel).sendMessageEmbeds(any(), any()); + doReturn(messageAction).when(privateChannel).sendMessageEmbeds(any()); } /** @@ -246,6 +265,8 @@ public JdaTester() { /** * Creates a Mockito spy for a member with the given user id. + *

+ * See {@link #getMemberSpy()} to get the default member used by this tester. * * @param userId the id of the member to create * @return the created spy @@ -255,6 +276,18 @@ public JdaTester() { return spy(new MemberImpl(guild, user)); } + /** + * Creates a Mockito spy for a text channel with the given channel id. + *

+ * See {@link #getTextChannelSpy()} to get the default text channel used by this tester. + * + * @param channelId the id of the text channel to create + * @return the created spy + */ + public @NotNull TextChannel createTextChannelSpy(long channelId) { + return spy(new TextChannelImpl(channelId, guild)); + } + /** * Gets the Mockito mock used as universal reply action by all mocks created by this tester * instance. @@ -294,6 +327,42 @@ public JdaTester() { return textChannel; } + /** + * Gets the private channel spy used as universal private channel by all mocks created by this + * tester instance. + *

+ * For example {@link JDA#openPrivateChannelById(long)} will return this spy if used on the + * instance returned by {@link #getJdaMock()}. + * + * @return the private channel spy used by this tester + */ + public @NotNull PrivateChannel getPrivateChannelSpy() { + return privateChannel; + } + + /** + * Gets the member spy used as universal member by all mocks created by this tester instance. + *

+ * For example the events created by {@link #createSlashCommandInteractionEvent(SlashCommand)} + * will return this spy on several of their methods. + *

+ * See {@link #createMemberSpy(long)} to create other members. + * + * @return the member spy used by this tester + */ + public @NotNull Member getMemberSpy() { + return member; + } + + /** + * Gets the JDA mock used as universal instance by all mocks created by this tester instance. + * + * @return the JDA mock used by this tester + */ + public @NotNull JDA getJdaMock() { + return jda; + } + /** * Creates a mocked action that always succeeds and consumes the given object. *

@@ -325,11 +394,25 @@ public JdaTester() { successConsumer.accept(t); return null; }; + Answer> mapExecution = invocation -> { + Function mapFunction = invocation.getArgument(0); + Object result = mapFunction.apply(t); + return createSucceededActionMock(result); + }; + Answer> flatMapExecution = invocation -> { + Function> flatMapFunction = invocation.getArgument(0); + return flatMapFunction.apply(t); + }; doNothing().when(action).queue(); doAnswer(successExecution).when(action).queue(any()); doAnswer(successExecution).when(action).queue(any(), any()); + when(action.onErrorMap(any())).thenReturn(action); + when(action.onErrorMap(any(), any())).thenReturn(action); + + doAnswer(mapExecution).when(action).map(any()); + doAnswer(flatMapExecution).when(action).flatMap(any()); return action; } @@ -366,11 +449,27 @@ public JdaTester() { return null; }; + Answer> errorMapExecution = invocation -> { + Function mapFunction = invocation.getArgument(0); + Object result = mapFunction.apply(failureReason); + return createSucceededActionMock(result); + }; + + Answer> mapExecution = invocation -> createFailedActionMock(failureReason); + Answer> flatMapExecution = + invocation -> createFailedActionMock(failureReason); + doNothing().when(action).queue(); doNothing().when(action).queue(any()); + doAnswer(errorMapExecution).when(action).onErrorMap(any()); + doAnswer(errorMapExecution).when(action).onErrorMap(any(), any()); + doAnswer(failureExecution).when(action).queue(any(), any()); + doAnswer(mapExecution).when(action).map(any()); + doAnswer(flatMapExecution).when(action).flatMap(any()); + return action; } diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java index ec414445dd..4f4323944d 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; 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; @@ -103,6 +103,26 @@ public final class SlashCommandInteractionEventBuilder { return this; } + /** + * Sets the given option, overriding an existing value under the same name. + *

+ * If {@link #setSubcommand(String)} is set, this option will be interpreted as option to the + * subcommand. + *

+ * Use {@link #clearOptions()} to clear any set options. + * + * @param name the name of the option + * @param value the value of the option + * @return this builder instance for chaining + * @throws IllegalArgumentException if the option does not exist in the corresponding command, + * as specified by its {@link SlashCommand#getData()} + */ + public @NotNull SlashCommandInteractionEventBuilder setOption(@NotNull String name, + long value) { + putOptionRaw(name, value, OptionType.INTEGER); + return this; + } + /** * Sets the given option, overriding an existing value under the same name. *

@@ -292,6 +312,14 @@ private SlashCommandInteractionEvent spySlashCommandEvent(String jsonData) { @NotNull OptionType type) { if (type == OptionType.STRING) { return (String) value; + } else if (type == OptionType.INTEGER) { + if (value instanceof Long asLong) { + return value.toString(); + } + + throw new IllegalArgumentException( + "Expected a long, since the type was set to INTEGER. But got '%s'" + .formatted(value.getClass())); } else if (type == OptionType.USER) { if (value instanceof User user) { return user.getId();