diff --git a/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java b/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java index 50fb91215..13b8a1001 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java @@ -12,11 +12,16 @@ import org.patinanetwork.codebloom.common.db.models.discord.DiscordClub; import org.patinanetwork.codebloom.common.db.models.discord.DiscordClubMetadata; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; +import org.patinanetwork.codebloom.common.db.models.user.User; import org.patinanetwork.codebloom.common.db.models.user.UserWithScore; import org.patinanetwork.codebloom.common.db.repos.discord.club.DiscordClubRepository; import org.patinanetwork.codebloom.common.db.repos.leaderboard.LeaderboardRepository; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterGenerator; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterOptions; +import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; +import org.patinanetwork.codebloom.common.db.repos.user.options.UserFilterOptions; +import org.patinanetwork.codebloom.common.dto.refresh.RefreshResultDto; +import org.patinanetwork.codebloom.common.page.Indexed; import org.patinanetwork.codebloom.common.time.StandardizedLocalDateTime; import org.patinanetwork.codebloom.common.url.ServerUrlUtils; import org.patinanetwork.codebloom.common.utils.leaderboard.LeaderboardUtils; @@ -31,17 +36,23 @@ public class DiscordClubManager { private final JDAClient jdaClient; private final LeaderboardRepository leaderboardRepository; private final DiscordClubRepository discordClubRepository; + private final UserRepository userRepository; private final ServerUrlUtils serverUrlUtils; + private final LeaderboardManager leaderboardManager; public DiscordClubManager( final ServerUrlUtils serverUrlUtils, final JDAClient jdaClient, final LeaderboardRepository leaderboardRepository, - final DiscordClubRepository discordClubRepository) { + final DiscordClubRepository discordClubRepository, + final UserRepository userRepository, + final LeaderboardManager leaderboardManager) { this.serverUrlUtils = serverUrlUtils; this.jdaClient = jdaClient; this.leaderboardRepository = leaderboardRepository; this.discordClubRepository = discordClubRepository; + this.userRepository = userRepository; + this.leaderboardManager = leaderboardManager; } private static final String[] MEDAL_EMOJIS = {"🥇", "🥈", "🥉"}; @@ -270,6 +281,37 @@ public MessageCreateData buildLeaderboardMessageForClub(String guildId, boolean return MessageCreateData.fromEmbeds(embed); } + public RefreshResultDto refreshSubmissions(String guildId, String discordId) throws LeaderboardException { + DiscordClub club = discordClubRepository + .getDiscordClubByGuildId(guildId) + .orElseThrow(() -> new LeaderboardException("Club does not exist", "This club does not exist!")); + + User user = leaderboardManager.refreshUserSubmissions(discordId); + Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + + LeaderboardFilterOptions options = + LeaderboardFilterGenerator.builderWithTag(club.getTag()).build(); + + String userId = user.getId(); + + UserWithScore scoredUser = userRepository.getUserWithScoreByIdAndLeaderboardId( + userId, currentLeaderboard.getId(), UserFilterOptions.DEFAULT); + + int score = scoredUser.getTotalScore(); + Indexed globalIndex = + leaderboardRepository.getGlobalRankedUserById(currentLeaderboard.getId(), userId); + Indexed clubIndex = + leaderboardRepository.getFilteredRankedUserById(currentLeaderboard.getId(), userId, options); + + return RefreshResultDto.builder() + .score(score) + .globalRank(globalIndex.getIndex()) + .clubRank(clubIndex.getIndex()) + .leaderboardName(currentLeaderboard.getName()) + .clubName(club.getName()) + .build(); + } + public boolean sendTestEmbedMessageToClub(DiscordClub club) { try { String description = """ diff --git a/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardException.java b/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardException.java new file mode 100644 index 000000000..3ecd5f612 --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardException.java @@ -0,0 +1,18 @@ +package org.patinanetwork.codebloom.common.components; + +import lombok.Getter; + +@Getter +public class LeaderboardException extends Exception { + private final String title; + private final String description; + + public LeaderboardException(Throwable t) { + this("Something went wrong!", t.getMessage()); + } + + public LeaderboardException(String title, String description) { + this.title = title; + this.description = description; + } +} diff --git a/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardManager.java b/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardManager.java index 72ffa154d..35012411f 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardManager.java @@ -5,15 +5,20 @@ import org.patinanetwork.codebloom.common.db.models.achievements.Achievement; import org.patinanetwork.codebloom.common.db.models.achievements.AchievementPlaceEnum; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; +import org.patinanetwork.codebloom.common.db.models.user.User; import org.patinanetwork.codebloom.common.db.models.user.UserWithScore; import org.patinanetwork.codebloom.common.db.models.usertag.Tag; import org.patinanetwork.codebloom.common.db.repos.achievements.AchievementRepository; import org.patinanetwork.codebloom.common.db.repos.leaderboard.LeaderboardRepository; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterGenerator; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterOptions; +import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; import org.patinanetwork.codebloom.common.dto.user.UserWithScoreDto; +import org.patinanetwork.codebloom.common.leetcode.LeetcodeClient; +import org.patinanetwork.codebloom.common.leetcode.models.LeetcodeSubmission; import org.patinanetwork.codebloom.common.page.Indexed; import org.patinanetwork.codebloom.common.page.Page; +import org.patinanetwork.codebloom.common.submissions.SubmissionsHandler; import org.patinanetwork.codebloom.common.utils.leaderboard.LeaderboardUtils; import org.patinanetwork.codebloom.common.utils.pair.Pair; import org.springframework.stereotype.Component; @@ -24,11 +29,21 @@ public class LeaderboardManager { private final LeaderboardRepository leaderboardRepository; private final AchievementRepository achievementRepository; + private final UserRepository userRepository; + private final LeetcodeClient leetcodeClient; + private final SubmissionsHandler submissionsHandler; public LeaderboardManager( - final LeaderboardRepository leaderboardRepository, final AchievementRepository achievementRepository) { + final LeaderboardRepository leaderboardRepository, + final AchievementRepository achievementRepository, + final UserRepository userRepository, + final LeetcodeClient leetcodeClient, + final SubmissionsHandler submissionsHandler) { this.leaderboardRepository = leaderboardRepository; this.achievementRepository = achievementRepository; + this.userRepository = userRepository; + this.leetcodeClient = leetcodeClient; + this.submissionsHandler = submissionsHandler; } public static final int MIN_POSSIBLE_WINNERS = 0; @@ -151,4 +166,33 @@ public Page> getLeaderboardUsers( new Page<>(hasNextPage, indexedUserWithScoreDtos, totalPages, parsedPageSize); return createdPage; } + + public User refreshUserSubmissions(final String discordId) throws LeaderboardException { + User user = userRepository.getUserByDiscordId(discordId); + + if (user == null) { + throw new LeaderboardException( + "Cannot refresh submissions", + "Please link your account by [logging in to Codebloom](https://codebloom.patinanetwork.org/login) and completing onboarding."); + } + + if (user.getLeetcodeUsername() == null) { + throw new LeaderboardException( + "Cannot refresh submissions", "Your Discord Account is not linked to a LeetCode username."); + } + try { + log.info("Fetching recent LeetCode submissions for user: {}", user.getLeetcodeUsername()); + List leetcodeSubmissions = + leetcodeClient.findSubmissionsByUsername(user.getLeetcodeUsername(), 20); + + submissionsHandler.handleSubmissions(leetcodeSubmissions, user, true); + } catch (Exception e) { + log.error("Failed to fetch or process submissions for user {}", user.getLeetcodeUsername(), e); + throw new LeaderboardException( + "Cannot refresh submissions", + "Failed to fetch or process submissions from LeetCode. Please try again later."); + } + + return user; + } } diff --git a/src/main/java/org/patinanetwork/codebloom/common/dto/refresh/RefreshResultDto.java b/src/main/java/org/patinanetwork/codebloom/common/dto/refresh/RefreshResultDto.java new file mode 100644 index 000000000..c08d6d401 --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/common/dto/refresh/RefreshResultDto.java @@ -0,0 +1,31 @@ +package org.patinanetwork.codebloom.common.dto.refresh; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.jackson.Jacksonized; + +@Getter +@Builder +@Jacksonized +@ToString +@EqualsAndHashCode +public class RefreshResultDto { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int score; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int globalRank; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int clubRank; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String leaderboardName; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String clubName; +} diff --git a/src/main/java/org/patinanetwork/codebloom/common/page/Indexed.java b/src/main/java/org/patinanetwork/codebloom/common/page/Indexed.java index d8cc43a5c..ebb4929ca 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/page/Indexed.java +++ b/src/main/java/org/patinanetwork/codebloom/common/page/Indexed.java @@ -24,7 +24,6 @@ @ToString @EqualsAndHashCode public class Indexed { - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private final int index; diff --git a/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommand.java b/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommand.java index 2aa17e788..49763096f 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommand.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommand.java @@ -10,7 +10,8 @@ @AllArgsConstructor @ToString public enum JDASlashCommand { - LEADERBOARD("leaderboard", "Shows the current weekly leaderboard"); + LEADERBOARD("leaderboard", "Shows the current weekly leaderboard"), + REFRESH("refresh", "Refresh submissions manually"); private final String command; private final String description; diff --git a/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommandHandler.java b/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommandHandler.java index 5535c7a8f..e1a8b26da 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommandHandler.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/command/JDASlashCommandHandler.java @@ -1,72 +1,188 @@ package org.patinanetwork.codebloom.jda.command; import java.awt.Color; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.utils.messages.MessageCreateData; import org.patinanetwork.codebloom.common.components.DiscordClubManager; +import org.patinanetwork.codebloom.common.components.LeaderboardException; +import org.patinanetwork.codebloom.common.dto.refresh.RefreshResultDto; import org.patinanetwork.codebloom.common.simpleredis.SimpleRedis; import org.patinanetwork.codebloom.common.simpleredis.SimpleRedisProvider; import org.patinanetwork.codebloom.common.simpleredis.SimpleRedisSlot; import org.springframework.stereotype.Component; @Component +@Slf4j public class JDASlashCommandHandler extends ListenerAdapter { private final DiscordClubManager discordClubManager; private final SimpleRedis simpleRedis; + private final ExecutorService pool; public JDASlashCommandHandler( DiscordClubManager discordClubManager, final SimpleRedisProvider simpleRedisProvider) { this.discordClubManager = discordClubManager; this.simpleRedis = simpleRedisProvider.select(SimpleRedisSlot.JDA_COOLDOWN); + this.pool = Executors.newVirtualThreadPerTaskExecutor(); } @Override public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { switch (JDASlashCommand.fromCommand(event.getName())) { case LEADERBOARD -> handleLeaderboardSlashCommand(event); + case REFRESH -> handleRefreshSlashCommand(event); default -> throw new IllegalArgumentException("Unknown slash command: " + event.getName()); } } - private void handleLeaderboardSlashCommand(SlashCommandInteractionEvent event) { - - if (event.getGuild() == null) { - event.reply("This command can only be used in a server.") - .setEphemeral(true) - .queue(); - return; - } - + /** + * In memory rate limiter. + * + * @param event Slash command event + * @param time Time to reuse in seconds + */ + private long handleRedis(SlashCommandInteractionEvent event, final long time) { String guildId = event.getGuild().getId(); + String command = event.getName(); + String key = "refresh".equals(command) + ? guildId + ":" + command + ":" + event.getUser().getId() + : guildId + ":" + command; - if (simpleRedis.containsKey(guildId)) { - long timeThen = simpleRedis.get(guildId); + if (simpleRedis.containsKey(key)) { + long timeThen = simpleRedis.get(key); long timeNow = System.currentTimeMillis(); long difference = (timeNow - timeThen) / 1000; - if (difference < 600) { - long remainingTime = 600 - difference; - long minutes = remainingTime / 60; - long seconds = remainingTime % 60; + if (difference < time) { + long remainingTime = time - difference; - EmbedBuilder embed = new EmbedBuilder() - .setTitle("Please try again in **" + minutes + " minutes and " + seconds + " seconds**.") - .setColor(new Color(69, 129, 103)); + return remainingTime; + } + } - event.replyEmbeds(embed.build()).setEphemeral(true).queue(); + simpleRedis.put(key, System.currentTimeMillis()); + return 0; + } - return; + private void handleRefreshSlashCommand(SlashCommandInteractionEvent event) { + event.deferReply().setEphemeral(true).queue(); + + if (event.getGuild() == null) { + MessageEmbed message = new EmbedBuilder() + .setTitle("This command can only be used in a server") + .setColor(Color.RED) + .build(); + event.getHook().sendMessageEmbeds(message).queue(); + return; + } + long remainingTime = handleRedis(event, (long) 5 * 60); + long minutes = remainingTime / 60; + long seconds = remainingTime % 60; + + if (remainingTime != 0) { + String description = String.format(""" + Please wait %s minutes and %s seconds before refreshing! + """, minutes, seconds); + + MessageEmbed message = new EmbedBuilder() + .setTitle("⏳ You are refreshing too quickly!") + .setDescription(description) + .setColor(Color.ORANGE) + .build(); + + event.getHook().editOriginalEmbeds(message).queue(); + return; + } + String guildId = event.getGuild().getId(); + String userId = event.getUser().getId(); + var future = pool.submit(() -> { + try { + RefreshResultDto result = discordClubManager.refreshSubmissions(guildId, userId); + + String description = String.format( + """ + After refreshing your submissions, you currently have %s points. + + For the leaderboard `%s`, you are currently #%s globally and #%s among all %s members. + """, + result.getScore(), + result.getLeaderboardName(), + result.getGlobalRank(), + result.getClubRank(), + result.getClubName()); + + MessageEmbed message = new EmbedBuilder() + .setTitle("Submissions Refreshed") + .setDescription(description) + .setColor(new Color(69, 129, 103)) + .build(); + + event.getHook().editOriginalEmbeds(message).queue(); + } catch (LeaderboardException e) { + MessageEmbed message = new EmbedBuilder() + .setTitle(e.getTitle()) + .setDescription(e.getDescription()) + .setColor(new Color(24, 162, 184)) + .build(); + event.getHook().editOriginalEmbeds(message).queue(); + log.error("Refresh for club failed ", e); } + }); + + try { + future.get(2500, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (TimeoutException | ExecutionException exception) { + String description = """ + Hmm, the refresh operation is taking longer than expected :( + + We’ll keep trying behind the scenes. On completion, this message will update to reflect your points and rank. + """; + MessageEmbed message = new EmbedBuilder() + .setTitle("Submissions Refreshed") + .setDescription(description) + .setColor(new Color(24, 162, 184)) + .build(); + event.getHook().editOriginalEmbeds(message).queue(); + log.info("Refresh for club taking longer than expected ", exception); } + } - simpleRedis.put(guildId, System.currentTimeMillis()); - - MessageCreateData message = discordClubManager.buildLeaderboardMessageForClub( - event.getGuild().getId(), false); + private void handleLeaderboardSlashCommand(SlashCommandInteractionEvent event) { + if (event.getGuild() == null) { + event.reply("This command can only be used in a server.") + .setEphemeral(true) + .queue(); + return; + } + long remainingTime = handleRedis(event, (long) 10 * 60); + long minutes = remainingTime / 60; + long seconds = remainingTime % 60; + + String title = String.format(""" + Please try again in %s minutes and %s seconds. + """, minutes, seconds); + if (remainingTime != 0) { + MessageEmbed message = new EmbedBuilder() + .setTitle(title) + .setColor(new Color(69, 129, 103)) + .build(); + event.replyEmbeds(message).setEphemeral(true).queue(); + return; + } + String serverId = event.getGuild().getId(); + MessageCreateData message = discordClubManager.buildLeaderboardMessageForClub(serverId, false); - event.reply(message).queue(); + event.reply(message).setEphemeral(true).queue(); } } diff --git a/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java b/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java index d639a5d90..94c3db368 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java @@ -21,11 +21,16 @@ import org.patinanetwork.codebloom.common.db.models.discord.DiscordClub; import org.patinanetwork.codebloom.common.db.models.discord.DiscordClubMetadata; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; +import org.patinanetwork.codebloom.common.db.models.user.User; import org.patinanetwork.codebloom.common.db.models.user.UserWithScore; import org.patinanetwork.codebloom.common.db.models.usertag.Tag; import org.patinanetwork.codebloom.common.db.repos.discord.club.DiscordClubRepository; import org.patinanetwork.codebloom.common.db.repos.leaderboard.LeaderboardRepository; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterOptions; +import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; +import org.patinanetwork.codebloom.common.db.repos.user.options.UserFilterOptions; +import org.patinanetwork.codebloom.common.dto.refresh.RefreshResultDto; +import org.patinanetwork.codebloom.common.page.Indexed; import org.patinanetwork.codebloom.common.url.ServerUrlUtils; import org.patinanetwork.codebloom.jda.client.JDAClient; import org.patinanetwork.codebloom.jda.client.options.EmbeddedImagesMessageOptions; @@ -39,6 +44,8 @@ public class DiscordClubManagerTest { private DiscordClubRepository discordClubRepository = mock(DiscordClubRepository.class); private PlaywrightClient playwrightClient = mock(PlaywrightClient.class); private ServerUrlUtils serverUrlUtils = mock(ServerUrlUtils.class); + private UserRepository userRepository = mock(UserRepository.class); + private LeaderboardManager leaderboardManager = mock(LeaderboardManager.class); private DiscordClubManager discordClubManager; @@ -46,8 +53,13 @@ public class DiscordClubManagerTest { @BeforeEach void setUp() { - discordClubManager = - new DiscordClubManager(serverUrlUtils, jdaClient, leaderboardRepository, discordClubRepository); + discordClubManager = new DiscordClubManager( + serverUrlUtils, + jdaClient, + leaderboardRepository, + discordClubRepository, + userRepository, + leaderboardManager); logWatcher = new ListAppender<>(); logWatcher.start(); @@ -371,6 +383,152 @@ void testSendTestEmbedMessageToClubSuccess() { assertTrue(description.contains("test message")); } + @Test + void testRefreshSubmissionsSuccess() throws LeaderboardException { + DiscordClub club = createMockDiscordClub("Test Club", Tag.Rpi); + when(discordClubRepository.getDiscordClubByGuildId("guild-123")).thenReturn(Optional.of(club)); + + User mockUser = mock(User.class); + when(mockUser.getId()).thenReturn("user-id-1"); + when(leaderboardManager.refreshUserSubmissions("discord-456")).thenReturn(mockUser); + + Leaderboard mockLeaderboard = mock(Leaderboard.class); + when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); + when(mockLeaderboard.getName()).thenReturn("Week 10"); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + + UserWithScore scoredUser = mock(UserWithScore.class); + when(scoredUser.getTotalScore()).thenReturn(42); + when(userRepository.getUserWithScoreByIdAndLeaderboardId( + eq("user-id-1"), eq("leaderboard-id"), eq(UserFilterOptions.DEFAULT))) + .thenReturn(scoredUser); + + Indexed globalIndex = Indexed.of(scoredUser, 5); + Indexed clubIndex = Indexed.of(scoredUser, 2); + when(leaderboardRepository.getGlobalRankedUserById("leaderboard-id", "user-id-1")) + .thenReturn(globalIndex); + when(leaderboardRepository.getFilteredRankedUserById( + eq("leaderboard-id"), eq("user-id-1"), any(LeaderboardFilterOptions.class))) + .thenReturn(clubIndex); + + RefreshResultDto result = discordClubManager.refreshSubmissions("guild-123", "discord-456"); + + assertEquals(42, result.getScore()); + assertEquals(5, result.getGlobalRank()); + assertEquals(2, result.getClubRank()); + assertEquals("Week 10", result.getLeaderboardName()); + assertEquals("Test Club", result.getClubName()); + + verify(leaderboardManager).refreshUserSubmissions("discord-456"); + verify(discordClubRepository).getDiscordClubByGuildId("guild-123"); + } + + @Test + void testRefreshSubmissionsClubNotFound() { + when(discordClubRepository.getDiscordClubByGuildId("unknown-guild")).thenReturn(Optional.empty()); + + LeaderboardException exception = assertThrows( + LeaderboardException.class, + () -> discordClubManager.refreshSubmissions("unknown-guild", "discord-456")); + + assertEquals("Club does not exist", exception.getTitle()); + assertEquals("This club does not exist!", exception.getDescription()); + + verifyNoInteractions(leaderboardManager); + verifyNoInteractions(userRepository); + } + + @Test + void testRefreshSubmissionsUserRefreshThrowsLeaderboardException() throws LeaderboardException { + DiscordClub club = createMockDiscordClub("Test Club", Tag.Rpi); + when(discordClubRepository.getDiscordClubByGuildId("guild-123")).thenReturn(Optional.of(club)); + + when(leaderboardManager.refreshUserSubmissions("discord-456")) + .thenThrow(new LeaderboardException( + "Cannot refresh submissions", "Your Discord Account is not linked to a LeetCode username.")); + + LeaderboardException exception = assertThrows( + LeaderboardException.class, () -> discordClubManager.refreshSubmissions("guild-123", "discord-456")); + + assertEquals("Cannot refresh submissions", exception.getTitle()); + assertEquals("Your Discord Account is not linked to a LeetCode username.", exception.getDescription()); + + verify(leaderboardManager).refreshUserSubmissions("discord-456"); + verifyNoInteractions(userRepository); + } + + @Test + void testRefreshSubmissionsReturnsCorrectClubRankWithDifferentTags() throws LeaderboardException { + DiscordClub club = createMockDiscordClub("Baruch Club", Tag.Baruch); + when(discordClubRepository.getDiscordClubByGuildId("guild-789")).thenReturn(Optional.of(club)); + + User mockUser = mock(User.class); + when(mockUser.getId()).thenReturn("user-id-2"); + when(leaderboardManager.refreshUserSubmissions("discord-111")).thenReturn(mockUser); + + Leaderboard mockLeaderboard = mock(Leaderboard.class); + when(mockLeaderboard.getId()).thenReturn("lb-id"); + when(mockLeaderboard.getName()).thenReturn("Week 5"); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + + UserWithScore scoredUser = mock(UserWithScore.class); + when(scoredUser.getTotalScore()).thenReturn(100); + when(userRepository.getUserWithScoreByIdAndLeaderboardId( + eq("user-id-2"), eq("lb-id"), eq(UserFilterOptions.DEFAULT))) + .thenReturn(scoredUser); + + Indexed globalIndex = Indexed.of(scoredUser, 10); + Indexed clubIndex = Indexed.of(scoredUser, 1); + when(leaderboardRepository.getGlobalRankedUserById("lb-id", "user-id-2")) + .thenReturn(globalIndex); + when(leaderboardRepository.getFilteredRankedUserById( + eq("lb-id"), eq("user-id-2"), any(LeaderboardFilterOptions.class))) + .thenReturn(clubIndex); + + RefreshResultDto result = discordClubManager.refreshSubmissions("guild-789", "discord-111"); + + assertEquals(100, result.getScore()); + assertEquals(10, result.getGlobalRank()); + assertEquals(1, result.getClubRank()); + assertEquals("Week 5", result.getLeaderboardName()); + assertEquals("Baruch Club", result.getClubName()); + } + + @Test + void testRefreshSubmissionsWithZeroScore() throws LeaderboardException { + DiscordClub club = createMockDiscordClub("Test Club", Tag.Rpi); + when(discordClubRepository.getDiscordClubByGuildId("guild-123")).thenReturn(Optional.of(club)); + + User mockUser = mock(User.class); + when(mockUser.getId()).thenReturn("user-id-1"); + when(leaderboardManager.refreshUserSubmissions("discord-456")).thenReturn(mockUser); + + Leaderboard mockLeaderboard = mock(Leaderboard.class); + when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); + when(mockLeaderboard.getName()).thenReturn("Week 1"); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + + UserWithScore scoredUser = mock(UserWithScore.class); + when(scoredUser.getTotalScore()).thenReturn(0); + when(userRepository.getUserWithScoreByIdAndLeaderboardId( + eq("user-id-1"), eq("leaderboard-id"), eq(UserFilterOptions.DEFAULT))) + .thenReturn(scoredUser); + + Indexed globalIndex = Indexed.of(scoredUser, 50); + Indexed clubIndex = Indexed.of(scoredUser, 20); + when(leaderboardRepository.getGlobalRankedUserById("leaderboard-id", "user-id-1")) + .thenReturn(globalIndex); + when(leaderboardRepository.getFilteredRankedUserById( + eq("leaderboard-id"), eq("user-id-1"), any(LeaderboardFilterOptions.class))) + .thenReturn(clubIndex); + + RefreshResultDto result = discordClubManager.refreshSubmissions("guild-123", "discord-456"); + + assertEquals(0, result.getScore()); + assertEquals(50, result.getGlobalRank()); + assertEquals(20, result.getClubRank()); + } + private DiscordClub createMockDiscordClub(final String name, final Tag tag) { DiscordClubMetadata metadata = mock(DiscordClubMetadata.class); when(metadata.getGuildId()).thenReturn(Optional.of("123456789")); diff --git a/src/test/java/org/patinanetwork/codebloom/common/components/LeaderboardManagerTest.java b/src/test/java/org/patinanetwork/codebloom/common/components/LeaderboardManagerTest.java index e5fd649a2..b5e5c588b 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/components/LeaderboardManagerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/components/LeaderboardManagerTest.java @@ -29,7 +29,10 @@ import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterGenerator; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterGeneratorTest; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterOptions; +import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; +import org.patinanetwork.codebloom.common.leetcode.LeetcodeClient; import org.patinanetwork.codebloom.common.page.Indexed; +import org.patinanetwork.codebloom.common.submissions.SubmissionsHandler; import org.patinanetwork.codebloom.common.time.StandardizedLocalDateTime; public class LeaderboardManagerTest { @@ -39,11 +42,15 @@ public class LeaderboardManagerTest { private LeaderboardRepository leaderboardRepository = mock(LeaderboardRepository.class); private AchievementRepository achievementRepository = mock(AchievementRepository.class); + private UserRepository userRepository = mock(UserRepository.class); + private LeetcodeClient leetcodeClient = mock(LeetcodeClient.class); + private SubmissionsHandler submissionsHandler = mock(SubmissionsHandler.class); private final int validLeaderboardTags = LeaderboardFilterGeneratorTest.VALID_LEADERBOARD_TAGS.size(); public LeaderboardManagerTest() { - this.leaderboardManager = new LeaderboardManager(leaderboardRepository, achievementRepository); + this.leaderboardManager = new LeaderboardManager( + leaderboardRepository, achievementRepository, userRepository, leetcodeClient, submissionsHandler); this.faker = Faker.instance(); } diff --git a/src/test/java/org/patinanetwork/codebloom/jda/JDASlashCommandHandlerTest.java b/src/test/java/org/patinanetwork/codebloom/jda/JDASlashCommandHandlerTest.java index 77c5afb90..844ce374e 100644 --- a/src/test/java/org/patinanetwork/codebloom/jda/JDASlashCommandHandlerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/jda/JDASlashCommandHandlerTest.java @@ -1,13 +1,19 @@ package org.patinanetwork.codebloom.jda; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.requests.restaction.WebhookMessageCreateAction; +import net.dv8tion.jda.api.requests.restaction.WebhookMessageEditAction; import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; import net.dv8tion.jda.api.utils.messages.MessageCreateData; import org.junit.jupiter.api.BeforeEach; @@ -16,6 +22,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.patinanetwork.codebloom.common.components.DiscordClubManager; +import org.patinanetwork.codebloom.common.components.LeaderboardException; +import org.patinanetwork.codebloom.common.dto.refresh.RefreshResultDto; import org.patinanetwork.codebloom.common.simpleredis.SimpleRedis; import org.patinanetwork.codebloom.common.simpleredis.SimpleRedisProvider; import org.patinanetwork.codebloom.common.simpleredis.SimpleRedisSlot; @@ -42,7 +50,7 @@ void setUp() { } @Test - void testOnSlashCommandInteractionNoGuild() { + void testLeaderboardNoGuild() { SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); ReplyCallbackAction replyAction = mock(ReplyCallbackAction.class); @@ -61,7 +69,7 @@ void testOnSlashCommandInteractionNoGuild() { } @Test - void testOnSlashCommandInteractionSuccess() { + void testLeaderboardSuccess() { SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); ReplyCallbackAction replyAction = mock(ReplyCallbackAction.class); Guild guild = mock(Guild.class); @@ -70,24 +78,53 @@ void testOnSlashCommandInteractionSuccess() { when(event.getGuild()).thenReturn(guild); when(guild.getId()).thenReturn("guild-123"); - when(simpleRedis.containsKey("guild-123")).thenReturn(false); + when(simpleRedis.containsKey("guild-123:leaderboard")).thenReturn(false); MessageCreateData message = mock(MessageCreateData.class); when(discordClubManager.buildLeaderboardMessageForClub("guild-123", false)) .thenReturn(message); when(event.reply(eq(message))).thenReturn(replyAction); + when(replyAction.setEphemeral(true)).thenReturn(replyAction); handler.onSlashCommandInteraction(event); - verify(simpleRedis).put(eq("guild-123"), anyLong()); + verify(simpleRedis).put(eq("guild-123:leaderboard"), anyLong()); verify(discordClubManager).buildLeaderboardMessageForClub("guild-123", false); verify(event).reply(message); + verify(replyAction).setEphemeral(true); verify(replyAction).queue(); } @Test - void testOnSlashCommandInteractionCooldown() { + void testLeaderboardCooldown() { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + ReplyCallbackAction cooldownReplyAction = mock(ReplyCallbackAction.class); + Guild guild = mock(Guild.class); + + when(event.getName()).thenReturn("leaderboard"); + when(event.getGuild()).thenReturn(guild); + when(guild.getId()).thenReturn("guild-123"); + + when(simpleRedis.containsKey("guild-123:leaderboard")).thenReturn(true); + when(simpleRedis.get("guild-123:leaderboard")).thenReturn(System.currentTimeMillis()); + + when(event.replyEmbeds(any(MessageEmbed.class))).thenReturn(cooldownReplyAction); + when(cooldownReplyAction.setEphemeral(true)).thenReturn(cooldownReplyAction); + + handler.onSlashCommandInteraction(event); + + verify(event).replyEmbeds(any(MessageEmbed.class)); + verify(cooldownReplyAction).setEphemeral(true); + verify(cooldownReplyAction).queue(); + + verify(simpleRedis, never()).put(eq("guild-123:leaderboard"), anyLong()); + verifyNoInteractions(discordClubManager); + verify(event, never()).reply(any(MessageCreateData.class)); + } + + @Test + void testLeaderboardCooldownExpired() { SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); ReplyCallbackAction replyAction = mock(ReplyCallbackAction.class); Guild guild = mock(Guild.class); @@ -96,19 +133,200 @@ void testOnSlashCommandInteractionCooldown() { when(event.getGuild()).thenReturn(guild); when(guild.getId()).thenReturn("guild-123"); - when(simpleRedis.containsKey("guild-123")).thenReturn(true); - when(simpleRedis.get("guild-123")).thenReturn(System.currentTimeMillis()); + when(simpleRedis.containsKey("guild-123:leaderboard")).thenReturn(true); + when(simpleRedis.get("guild-123:leaderboard")).thenReturn(System.currentTimeMillis() - 11 * 60 * 1000); - when(event.replyEmbeds(any(MessageEmbed.class))).thenReturn(replyAction); + MessageCreateData message = mock(MessageCreateData.class); + when(discordClubManager.buildLeaderboardMessageForClub("guild-123", false)) + .thenReturn(message); + when(event.reply(eq(message))).thenReturn(replyAction); when(replyAction.setEphemeral(true)).thenReturn(replyAction); handler.onSlashCommandInteraction(event); - verify(event).replyEmbeds(any(MessageEmbed.class)); + verify(simpleRedis).put(eq("guild-123:leaderboard"), anyLong()); + verify(event, never()).replyEmbeds(any(MessageEmbed.class)); + verify(discordClubManager).buildLeaderboardMessageForClub("guild-123", false); + verify(event).reply(message); verify(replyAction).setEphemeral(true); verify(replyAction).queue(); + } + + @Test + void testRefreshNoGuild() { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + ReplyCallbackAction deferAction = mock(ReplyCallbackAction.class); + InteractionHook hook = mock(InteractionHook.class); + WebhookMessageCreateAction sendAction = mock(WebhookMessageCreateAction.class); + + when(event.getName()).thenReturn("refresh"); + when(event.deferReply()).thenReturn(deferAction); + when(deferAction.setEphemeral(true)).thenReturn(deferAction); + when(event.getGuild()).thenReturn(null); + when(event.getHook()).thenReturn(hook); + when(hook.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction); + + handler.onSlashCommandInteraction(event); + + verify(event).deferReply(); + verify(deferAction).setEphemeral(true); + verify(deferAction).queue(); + verify(hook).sendMessageEmbeds(any(MessageEmbed.class)); + verify(sendAction).queue(); + verifyNoInteractions(discordClubManager); + } + + @Test + void testRefreshSuccess() throws LeaderboardException { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + ReplyCallbackAction deferAction = mock(ReplyCallbackAction.class); + InteractionHook hook = mock(InteractionHook.class); + WebhookMessageEditAction editAction = mock(WebhookMessageEditAction.class); + Guild guild = mock(Guild.class); + User user = mock(User.class); + + when(event.getName()).thenReturn("refresh"); + when(event.deferReply()).thenReturn(deferAction); + when(deferAction.setEphemeral(true)).thenReturn(deferAction); + when(event.getGuild()).thenReturn(guild); + when(event.getUser()).thenReturn(user); + when(guild.getId()).thenReturn("guild-456"); + when(user.getId()).thenReturn("user-789"); + when(event.getHook()).thenReturn(hook); + + when(simpleRedis.containsKey("guild-456:refresh:user-789")).thenReturn(false); + + RefreshResultDto result = RefreshResultDto.builder() + .score(42) + .globalRank(5) + .clubRank(2) + .leaderboardName("Week 10") + .clubName("TestClub") + .build(); + when(discordClubManager.refreshSubmissions("guild-456", "user-789")).thenReturn(result); + when(hook.editOriginalEmbeds(any(MessageEmbed.class))).thenReturn(editAction); + + handler.onSlashCommandInteraction(event); + + verify(event).deferReply(); + verify(deferAction).setEphemeral(true); + verify(deferAction).queue(); + verify(simpleRedis).put(eq("guild-456:refresh:user-789"), anyLong()); + verify(discordClubManager).refreshSubmissions("guild-456", "user-789"); + verify(hook).editOriginalEmbeds(any(MessageEmbed.class)); + verify(editAction).queue(); + } + + @Test + void testRefreshCooldown() { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + ReplyCallbackAction deferAction = mock(ReplyCallbackAction.class); + InteractionHook hook = mock(InteractionHook.class); + WebhookMessageEditAction editEmbedsAction = mock(WebhookMessageEditAction.class); + Guild guild = mock(Guild.class); + User user = mock(User.class); + + when(event.getName()).thenReturn("refresh"); + when(event.deferReply()).thenReturn(deferAction); + when(deferAction.setEphemeral(true)).thenReturn(deferAction); + when(event.getGuild()).thenReturn(guild); + when(event.getUser()).thenReturn(user); + when(guild.getId()).thenReturn("guild-456"); + when(user.getId()).thenReturn("user-789"); + when(event.getHook()).thenReturn(hook); + + when(simpleRedis.containsKey("guild-456:refresh:user-789")).thenReturn(true); + when(simpleRedis.get("guild-456:refresh:user-789")).thenReturn(System.currentTimeMillis()); + + when(hook.editOriginalEmbeds(any(MessageEmbed.class))).thenReturn(editEmbedsAction); + + handler.onSlashCommandInteraction(event); + + verify(event).deferReply(); + verify(hook).editOriginalEmbeds(any(MessageEmbed.class)); + verify(editEmbedsAction).queue(); + verify(simpleRedis, never()).put(eq("guild-456:refresh:user-789"), anyLong()); + verifyNoInteractions(discordClubManager); + } + + @Test + void testRefreshCooldownExpired() throws LeaderboardException { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + ReplyCallbackAction deferAction = mock(ReplyCallbackAction.class); + InteractionHook hook = mock(InteractionHook.class); + WebhookMessageEditAction editAction = mock(WebhookMessageEditAction.class); + Guild guild = mock(Guild.class); + User user = mock(User.class); + + when(event.getName()).thenReturn("refresh"); + when(event.deferReply()).thenReturn(deferAction); + when(deferAction.setEphemeral(true)).thenReturn(deferAction); + when(event.getGuild()).thenReturn(guild); + when(event.getUser()).thenReturn(user); + when(guild.getId()).thenReturn("guild-456"); + when(user.getId()).thenReturn("user-789"); + when(event.getHook()).thenReturn(hook); + + when(simpleRedis.containsKey("guild-456:refresh:user-789")).thenReturn(true); + when(simpleRedis.get("guild-456:refresh:user-789")).thenReturn(System.currentTimeMillis() - 6 * 60 * 1000); + + RefreshResultDto result = RefreshResultDto.builder() + .score(10) + .globalRank(3) + .clubRank(1) + .leaderboardName("Week 5") + .clubName("Club") + .build(); + when(discordClubManager.refreshSubmissions("guild-456", "user-789")).thenReturn(result); + when(hook.editOriginalEmbeds(any(MessageEmbed.class))).thenReturn(editAction); + + handler.onSlashCommandInteraction(event); + + verify(simpleRedis).put(eq("guild-456:refresh:user-789"), anyLong()); + verify(discordClubManager).refreshSubmissions("guild-456", "user-789"); + verify(hook).editOriginalEmbeds(any(MessageEmbed.class)); + verify(editAction).queue(); + } + + @Test + void testRefreshDiscordClubException() throws LeaderboardException { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + ReplyCallbackAction deferAction = mock(ReplyCallbackAction.class); + InteractionHook hook = mock(InteractionHook.class); + var editEmbedsAction = mock(WebhookMessageEditAction.class); + Guild guild = mock(Guild.class); + User user = mock(User.class); + + when(event.getName()).thenReturn("refresh"); + when(event.deferReply()).thenReturn(deferAction); + when(deferAction.setEphemeral(true)).thenReturn(deferAction); + when(event.getGuild()).thenReturn(guild); + when(event.getUser()).thenReturn(user); + when(guild.getId()).thenReturn("guild-456"); + when(user.getId()).thenReturn("user-789"); + when(event.getHook()).thenReturn(hook); + + when(simpleRedis.containsKey("guild-456:refresh:user-789")).thenReturn(false); + + when(discordClubManager.refreshSubmissions("guild-456", "user-789")) + .thenThrow(new LeaderboardException("Error Title", "Error Description")); + when(hook.editOriginalEmbeds(any(MessageEmbed.class))).thenReturn(editEmbedsAction); + + handler.onSlashCommandInteraction(event); + + verify(simpleRedis).put(eq("guild-456:refresh:user-789"), anyLong()); + verify(discordClubManager).refreshSubmissions("guild-456", "user-789"); + verify(hook).editOriginalEmbeds(any(MessageEmbed.class)); + verify(editEmbedsAction).queue(); + } + + @Test + void testUnknownSlashCommandThrowsException() { + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class); + when(event.getName()).thenReturn("unknown-command"); + + assertThrows(IllegalArgumentException.class, () -> handler.onSlashCommandInteraction(event)); verifyNoInteractions(discordClubManager); - verify(simpleRedis, never()).put(eq("guild-123"), anyLong()); // because you return before put } }