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 super T, ?> mapFunction = invocation.getArgument(0);
+ Object result = mapFunction.apply(t);
+ return createSucceededActionMock(result);
+ };
+ Answer> flatMapExecution = invocation -> {
+ Function super T, RestAction>> 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 super Throwable, ?> 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();