diff --git a/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java b/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java index 64ce7ff03..908019ec3 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java @@ -98,12 +98,13 @@ public ResponseEntity> createLeaderboard( // BE VERY CAREFUL WITH THIS ROUTE. IT WILL DEACTIVATE THE PREVIOUS LEADERBOARD // (however, it should be in a recoverable state, as it just gets toggled to be // deactivated, not deleted). - Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); - if (currentLeaderboard != null) { + Optional currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + + currentLeaderboard.ifPresent(lb -> { discordClubManager.sendLeaderboardCompletedDiscordMessageToAllClubs(); leaderboardManager.generateAchievementsForAllWinners(); - leaderboardRepository.disableLeaderboardById(currentLeaderboard.getId()); - } + leaderboardRepository.disableLeaderboardById(lb.getId()); + }); OffsetDateTime shouldExpireBy = StandardizedOffsetDateTime.normalize(newLeaderboardBody.getShouldExpireBy()); @@ -117,8 +118,8 @@ public ResponseEntity> createLeaderboard( Leaderboard newLeaderboard = Leaderboard.builder() .name(name) - .shouldExpireBy(shouldExpireBy != null ? shouldExpireBy.toLocalDateTime() : null) - .syntaxHighlightingLanguage(newLeaderboardBody.getSyntaxHighlightingLanguage()) + .shouldExpireBy(Optional.ofNullable(shouldExpireBy).map(d -> d.toLocalDateTime())) + .syntaxHighlightingLanguage(Optional.ofNullable(newLeaderboardBody.getSyntaxHighlightingLanguage())) .build(); leaderboardRepository.addNewLeaderboard(newLeaderboard); diff --git a/src/main/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandler.java b/src/main/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandler.java index 698a43f61..891e49934 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandler.java +++ b/src/main/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandler.java @@ -14,7 +14,6 @@ import net.dv8tion.jda.api.entities.Member; import org.patinanetwork.codebloom.common.db.models.Session; 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.usertag.UserTag; import org.patinanetwork.codebloom.common.db.repos.discord.club.DiscordClubRepository; @@ -111,8 +110,9 @@ public void onAuthenticationSuccess( .discordName(discordName) .build(); userRepository.createUser(newUser); - Leaderboard leaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); - leaderboardRepository.addUserToLeaderboard(newUser.getId(), leaderboard.getId()); + leaderboardRepository + .getRecentLeaderboardMetadata() + .ifPresent(lb -> leaderboardRepository.addUserToLeaderboard(newUser.getId(), lb.getId())); existingUser = newUser; } diff --git a/src/main/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardController.java b/src/main/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardController.java index b00ae46b6..55725cb6f 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import java.util.List; +import java.util.Optional; import org.patinanetwork.codebloom.common.components.LeaderboardManager; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; import org.patinanetwork.codebloom.common.db.models.user.UserWithScore; @@ -72,15 +73,15 @@ public ResponseEntity> getLeaderboardMetadataByLead final @PathVariable String leaderboardId, final HttpServletRequest request) { FakeLag.sleep(650); - Leaderboard leaderboardData = leaderboardManager.getLeaderboardMetadata(leaderboardId); + Optional leaderboardData = leaderboardManager.getLeaderboardMetadata(leaderboardId); - if (leaderboardData == null) { + if (leaderboardData.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Leaderboard cannot be found or does not exist."); } return ResponseEntity.ok() .body(ApiResponder.success( - "Leaderboard metadata found!", LeaderboardDto.fromLeaderboard(leaderboardData))); + "Leaderboard metadata found!", LeaderboardDto.fromLeaderboard(leaderboardData.get()))); } @GetMapping("/{leaderboardId}/user/all") @@ -177,11 +178,19 @@ public ResponseEntity> getCurrentLeaderboardMetadat final HttpServletRequest request) { FakeLag.sleep(650); - Leaderboard leaderboardData = leaderboardManager.getLeaderboardMetadata( - leaderboardRepository.getRecentLeaderboardMetadata().getId()); + var current = leaderboardRepository + .getRecentLeaderboardMetadata() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No active leaderboard found.")); + + Optional leaderboardData = leaderboardManager.getLeaderboardMetadata(current.getId()); + + if (leaderboardData.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Leaderboard cannot be found or does not exist."); + } return ResponseEntity.ok() - .body(ApiResponder.success("All leaderboards found!", LeaderboardDto.fromLeaderboard(leaderboardData))); + .body(ApiResponder.success( + "All leaderboards found!", LeaderboardDto.fromLeaderboard(leaderboardData.get()))); } @GetMapping("/current/user/all") @@ -256,8 +265,10 @@ public ResponseEntity>>> getCurrentL .mhcplusplus(mhcplusplus) .build(); - String currentLeaderboardId = - leaderboardRepository.getRecentLeaderboardMetadata().getId(); + String currentLeaderboardId = leaderboardRepository + .getRecentLeaderboardMetadata() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No active leaderboard found.")) + .getId(); Page> createdPage = leaderboardManager.getLeaderboardUsers(currentLeaderboardId, options, globalIndex); @@ -278,11 +289,17 @@ public ResponseEntity> getUserCurrentLeaderboardF final HttpServletRequest request, @PathVariable final String userId) { FakeLag.sleep(650); - Leaderboard leaderboardData = leaderboardRepository.getRecentLeaderboardMetadata(); + Optional leaderboardData = leaderboardRepository.getRecentLeaderboardMetadata(); + + if (leaderboardData.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Active leaderboard could not be found."); + } // we do not support point of time in this endpoint currently UserWithScore user = userRepository.getUserWithScoreByIdAndLeaderboardId( - userId, leaderboardData.getId(), UserFilterOptions.builder().build()); + userId, + leaderboardData.get().getId(), + UserFilterOptions.builder().build()); // if (user == null) { // return @@ -336,9 +353,9 @@ public ResponseEntity>> getUserCurrentLea AuthenticationObject authenticationObject = protector.validateSession(request); String userId = authenticationObject.getUser().getId(); - Leaderboard leaderboardData = leaderboardRepository.getRecentLeaderboardMetadata(); + Optional leaderboardData = leaderboardRepository.getRecentLeaderboardMetadata(); - if (leaderboardData == null) { + if (leaderboardData.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active leaderboard found."); } @@ -346,7 +363,10 @@ public ResponseEntity>> getUserCurrentLea if (!patina && !hunter && !nyu && !baruch && !rpi && !gwc && !sbu && !ccny && !columbia && !cornell && !bmcc) { // Use global ranking when no filters are applied - userWithRank = leaderboardRepository.getGlobalRankedUserById(leaderboardData.getId(), userId); + userWithRank = leaderboardRepository + .getGlobalRankedUserById(leaderboardData.get().getId(), userId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "User not found on the current leaderboard.")); } else { // Use filtered ranking when filters are applied LeaderboardFilterOptions options = LeaderboardFilterOptions.builder() @@ -362,13 +382,11 @@ public ResponseEntity>> getUserCurrentLea .cornell(cornell) .bmcc(bmcc) .build(); - userWithRank = leaderboardRepository.getFilteredRankedUserById(leaderboardData.getId(), userId, options); - } - - if (userWithRank == null) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, - "User not found on the current leaderboard or does not match the specified filters."); + userWithRank = leaderboardRepository + .getFilteredRankedUserById(leaderboardData.get().getId(), userId, options) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "User not found on the current leaderboard or does not match the specified filters.")); } Indexed indexedUserWithScoreDto = 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..8f6f3acde 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java @@ -79,15 +79,20 @@ private String buildTopUsersSection(final List users, final boole */ private void sendLeaderboardCompletedDiscordMessage(final DiscordClub club) { try { - Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + Optional currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + + if (currentLeaderboard.isEmpty()) { + return; + } LeaderboardFilterOptions options = LeaderboardFilterGenerator.builderWithTag(club.getTag()) .page(1) .pageSize(5) .build(); - List users = LeaderboardUtils.filterUsersWithPoints( - leaderboardRepository.getLeaderboardUsersById(currentLeaderboard.getId(), options)); + List users = + LeaderboardUtils.filterUsersWithPoints(leaderboardRepository.getLeaderboardUsersById( + currentLeaderboard.get().getId(), options)); String topUsersSection = buildTopUsersSection(users, true); String headerText = users.isEmpty() @@ -115,7 +120,7 @@ private void sendLeaderboardCompletedDiscordMessage(final DiscordClub club) { topUsersSection, club.getName(), serverUrlUtils.getUrl(), - currentLeaderboard.getId(), + currentLeaderboard.get().getId(), club.getTag().name().toLowerCase(), serverUrlUtils.getUrl()); @@ -132,7 +137,7 @@ private void sendLeaderboardCompletedDiscordMessage(final DiscordClub club) { .channelId(Long.valueOf(channelId.get())) .description(description) .title("🏆🏆🏆 %s - Final Leaderboard Score for %s" - .formatted(currentLeaderboard.getName(), club.getName())) + .formatted(currentLeaderboard.get().getName(), club.getName())) .footerText("Codebloom - LeetCode Leaderboard for %s".formatted(club.getName())) .footerIcon("%s/favicon.ico".formatted(serverUrlUtils.getUrl())) .color(new Color(69, 129, 103)) @@ -204,24 +209,33 @@ public MessageCreateData buildLeaderboardMessageForClub(String guildId, boolean DiscordClub club = discordClubRepository.getDiscordClubByGuildId(guildId).orElseThrow(); - Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + Optional currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + + if (currentLeaderboard.isEmpty()) { + throw new RuntimeException("No recent leaderboard found."); + } LeaderboardFilterOptions options = LeaderboardFilterGenerator.builderWithTag(club.getTag()) .page(1) .pageSize(5) .build(); - List users = LeaderboardUtils.filterUsersWithPoints( - leaderboardRepository.getLeaderboardUsersById(currentLeaderboard.getId(), options)); + List users = + LeaderboardUtils.filterUsersWithPoints(leaderboardRepository.getLeaderboardUsersById( + currentLeaderboard.get().getId(), options)); - LocalDateTime shouldExpireByTime = - Optional.ofNullable(currentLeaderboard.getShouldExpireBy()).orElse(StandardizedLocalDateTime.now()); + Optional shouldExpireByTime = currentLeaderboard.get().getShouldExpireBy(); - Duration remaining = Duration.between(StandardizedLocalDateTime.now(), shouldExpireByTime); + long daysLeft = 0; + long hoursLeft = 0; + long minutesLeft = 0; - long daysLeft = remaining.toDays(); - long hoursLeft = remaining.toHours() % 24; - long minutesLeft = remaining.toMinutes() % 60; + if (shouldExpireByTime.isPresent()) { + Duration remaining = Duration.between(StandardizedLocalDateTime.now(), shouldExpireByTime.get()); + daysLeft = remaining.toDays(); + hoursLeft = remaining.toHours() % 24; + minutesLeft = remaining.toMinutes() % 60; + } String topUsersSection = buildTopUsersSection(users, false); String headerText = "Here is %s on the LeetCode leaderboard for our very own members!" @@ -259,7 +273,7 @@ public MessageCreateData buildLeaderboardMessageForClub(String guildId, boolean MessageEmbed embed = new EmbedBuilder() .setTitle("%s - %s Leaderboard Update for %s" - .formatted(currentLeaderboard.getName(), isWeekly ? "Weekly" : "", club.getName())) + .formatted(currentLeaderboard.get().getName(), isWeekly ? "Weekly" : "", club.getName())) .setDescription(description) .setFooter( "Codebloom - LeetCode Leaderboard for %s".formatted(club.getName()), 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..3759a2718 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/LeaderboardManager.java @@ -1,6 +1,7 @@ package org.patinanetwork.codebloom.common.components; import java.util.List; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.patinanetwork.codebloom.common.db.models.achievements.Achievement; import org.patinanetwork.codebloom.common.db.models.achievements.AchievementPlaceEnum; @@ -49,9 +50,9 @@ private String calculatePlaceString(final int place) { public void generateAchievementsForAllWinners() { log.info("generating achievements for all winners..."); - Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + Optional currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); - if (currentLeaderboard == null) { + if (currentLeaderboard.isEmpty()) { return; } @@ -61,7 +62,7 @@ public void generateAchievementsForAllWinners() { for (var pair : filterOptsAndTags) { log.info("on leaderboard for {}", pair.getRight().getResolvedName()); List> users = leaderboardRepository.getRankedIndexedLeaderboardUsersById( - currentLeaderboard.getId(), pair.getLeft()); + currentLeaderboard.get().getId(), pair.getLeft()); List usersWithPoints = LeaderboardUtils.filterUsersWithPoints( users.stream().map(Indexed::getItem).toList()); List winners = usersWithPoints.subList(0, maxWinners(usersWithPoints.size())); @@ -77,7 +78,9 @@ public void generateAchievementsForAllWinners() { .leaderboard(pair.getRight()) .title(String.format( "%s - %s - %s Place", - currentLeaderboard.getName(), pair.getRight().getResolvedName(), placeString)) + currentLeaderboard.get().getName(), + pair.getRight().getResolvedName(), + placeString)) .build(); achievementRepository.createAchievement(achievement); } @@ -85,27 +88,31 @@ public void generateAchievementsForAllWinners() { // handle global leaderboard List> users = leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById( - currentLeaderboard.getId(), LeaderboardFilterOptions.DEFAULT); + currentLeaderboard.get().getId(), LeaderboardFilterOptions.DEFAULT); List usersWithPoints = LeaderboardUtils.filterUsersWithPoints( users.stream().map(Indexed::getItem).toList()); List winners = usersWithPoints.subList(0, maxWinners(usersWithPoints.size())); for (int i = 0; i < winners.size(); i++) { int place = i + 1; - log.info("on leaderboard for {} for global winner #{}", currentLeaderboard.getName(), place); + log.info( + "on leaderboard for {} for global winner #{}", + currentLeaderboard.get().getName(), + place); String placeString = calculatePlaceString(place); UserWithScore user = winners.get(i); Achievement achievement = Achievement.builder() .userId(user.getId()) .place(AchievementPlaceEnum.fromInteger(place)) .leaderboard(null) - .title(String.format("%s - %s Place", currentLeaderboard.getName(), placeString)) + .title(String.format( + "%s - %s Place", currentLeaderboard.get().getName(), placeString)) .build(); achievementRepository.createAchievement(achievement); } } - public Leaderboard getLeaderboardMetadata(final String id) { + public Optional getLeaderboardMetadata(final String id) { return leaderboardRepository.getLeaderboardMetadataById(id); } diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/models/leaderboard/Leaderboard.java b/src/main/java/org/patinanetwork/codebloom/common/db/models/leaderboard/Leaderboard.java index f0e072e70..350dbdece 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/models/leaderboard/Leaderboard.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/models/leaderboard/Leaderboard.java @@ -1,13 +1,13 @@ package org.patinanetwork.codebloom.common.db.models.leaderboard; import java.time.LocalDateTime; +import java.util.Optional; +import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; import lombok.ToString; import lombok.experimental.SuperBuilder; -import org.patinanetwork.codebloom.common.db.helper.annotations.NotNullColumn; -import org.patinanetwork.codebloom.common.db.helper.annotations.NullColumn; @Getter @Setter @@ -16,21 +16,18 @@ @ToString public class Leaderboard { - @NotNullColumn private String id; - @NotNullColumn private String name; - @NotNullColumn private LocalDateTime createdAt; - @NullColumn - private LocalDateTime deletedAt; + @Builder.Default + private Optional deletedAt = Optional.empty(); - @NullColumn - private LocalDateTime shouldExpireBy; + @Builder.Default + private Optional shouldExpireBy = Optional.empty(); - @NullColumn - private String syntaxHighlightingLanguage; + @Builder.Default + private Optional syntaxHighlightingLanguage = Optional.empty(); } diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepository.java b/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepository.java index fbe6a59b4..ef941247e 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepository.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepository.java @@ -1,15 +1,16 @@ package org.patinanetwork.codebloom.common.db.repos.leaderboard; import java.util.List; +import java.util.Optional; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; import org.patinanetwork.codebloom.common.db.models.user.UserWithScore; import org.patinanetwork.codebloom.common.db.repos.leaderboard.options.LeaderboardFilterOptions; import org.patinanetwork.codebloom.common.page.Indexed; public interface LeaderboardRepository { - Leaderboard getRecentLeaderboardMetadata(); + Optional getRecentLeaderboardMetadata(); - Leaderboard getLeaderboardMetadataById(String id); + Optional getLeaderboardMetadataById(String id); /** @deprecated This method is no longer recommended. Use {@link #getLeaderboardUsersById} instead. */ @Deprecated @@ -47,9 +48,9 @@ List> getRankedIndexedLeaderboardUsersById( * * @param leaderboardId The ID of the leaderboard * @param userId The ID of the user to fetch - * @return The user with their global rank, or null if user not found + * @return The user with their global rank, or empty if user not found */ - Indexed getGlobalRankedUserById(String leaderboardId, String userId); + Optional> getGlobalRankedUserById(String leaderboardId, String userId); /** * Returns a specific user's filtered rank on the leaderboard wrapped in {@code Indexed}. The rank is calculated @@ -58,9 +59,9 @@ List> getRankedIndexedLeaderboardUsersById( * @param leaderboardId The ID of the leaderboard * @param userId The ID of the user to fetch * @param options The filter options to apply when calculating rank - * @return The user with their filtered rank, or null if user not found + * @return The user with their filtered rank, or empty if user not found */ - Indexed getFilteredRankedUserById( + Optional> getFilteredRankedUserById( String leaderboardId, String userId, LeaderboardFilterOptions options); boolean disableLeaderboardById(String leaderboardId); diff --git a/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardSqlRepository.java b/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardSqlRepository.java index fcef954f4..635e9b507 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardSqlRepository.java +++ b/src/main/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardSqlRepository.java @@ -39,14 +39,12 @@ private Leaderboard parseResultSetToLeaderboard(final ResultSet resultSet) throw return Leaderboard.builder() .id(resultSet.getString("id")) .createdAt(resultSet.getTimestamp("createdAt").toLocalDateTime()) - .deletedAt(Optional.ofNullable(resultSet.getTimestamp("deletedAt")) - .map(Timestamp::toLocalDateTime) - .orElse(null)) + .deletedAt( + Optional.ofNullable(resultSet.getTimestamp("deletedAt")).map(Timestamp::toLocalDateTime)) .name(resultSet.getString("name")) .shouldExpireBy(Optional.ofNullable(resultSet.getTimestamp("shouldExpireBy")) - .map(Timestamp::toLocalDateTime) - .orElse(null)) - .syntaxHighlightingLanguage(resultSet.getString("syntaxHighlightingLanguage")) + .map(Timestamp::toLocalDateTime)) + .syntaxHighlightingLanguage(Optional.ofNullable(resultSet.getString("syntaxHighlightingLanguage"))) .build(); } @@ -88,8 +86,10 @@ public void addNewLeaderboard(final Leaderboard leaderboard) { NamedPreparedStatement stmt = new NamedPreparedStatement(conn, sql)) { stmt.setObject("id", UUID.fromString(leaderboard.getId())); stmt.setString("name", leaderboard.getName()); - stmt.setObject("shouldExpireBy", leaderboard.getShouldExpireBy()); - stmt.setString("syntaxHighlightingLanguage", leaderboard.getSyntaxHighlightingLanguage()); + stmt.setObject("shouldExpireBy", leaderboard.getShouldExpireBy().orElse(null)); + stmt.setString( + "syntaxHighlightingLanguage", + leaderboard.getSyntaxHighlightingLanguage().orElse(null)); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { var createdAt = rs.getTimestamp("createdAt").toLocalDateTime(); @@ -102,7 +102,7 @@ public void addNewLeaderboard(final Leaderboard leaderboard) { } @Override - public Leaderboard getRecentLeaderboardMetadata() { + public Optional getRecentLeaderboardMetadata() { String sql = """ SELECT id, @@ -122,18 +122,18 @@ public Leaderboard getRecentLeaderboardMetadata() { PreparedStatement stmt = conn.prepareStatement(sql)) { try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return parseResultSetToLeaderboard(rs); + return Optional.of(parseResultSetToLeaderboard(rs)); } } } catch (SQLException e) { throw new RuntimeException("Failed to fetch recent leaderboard metadata", e); } - return null; + return Optional.empty(); } @Override - public Leaderboard getLeaderboardMetadataById(final String id) { + public Optional getLeaderboardMetadataById(final String id) { String sql = """ SELECT id, @@ -152,14 +152,14 @@ public Leaderboard getLeaderboardMetadataById(final String id) { stmt.setObject("id", UUID.fromString(id)); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return parseResultSetToLeaderboard(rs); + return Optional.of(parseResultSetToLeaderboard(rs)); } } } catch (SQLException e) { throw new RuntimeException("Failed to fetch recent leaderboard metadata", e); } - return null; + return Optional.empty(); } @Override @@ -340,11 +340,11 @@ WITH ranks AS ( } @Override - public Indexed getGlobalRankedUserById(final String leaderboardId, final String userId) { + public Optional> getGlobalRankedUserById(final String leaderboardId, final String userId) { UserWithScore user = userRepository.getUserWithScoreByIdAndLeaderboardId( userId, leaderboardId, UserFilterOptions.builder().build()); if (user == null) { - return null; + return Optional.empty(); } String sql = """ @@ -387,23 +387,23 @@ WITH ranks AS ( try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { int rank = rs.getInt("rank"); - return Indexed.of(user, rank); + return Optional.of(Indexed.of(user, rank)); } } } catch (SQLException e) { throw new RuntimeException("Failed to get global rank for user", e); } - return null; + return Optional.empty(); } @Override - public Indexed getFilteredRankedUserById( + public Optional> getFilteredRankedUserById( final String leaderboardId, final String userId, final LeaderboardFilterOptions options) { UserWithScore user = userRepository.getUserWithScoreByIdAndLeaderboardId( userId, leaderboardId, UserFilterOptions.builder().build()); if (user == null) { - return null; + return Optional.empty(); } String sql = """ @@ -494,14 +494,14 @@ WITH ranks AS ( try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { int rank = rs.getInt("rank"); - return Indexed.of(user, rank); + return Optional.of(Indexed.of(user, rank)); } } } catch (SQLException e) { throw new RuntimeException("Failed to get filtered rank for user", e); } - return null; + return Optional.empty(); } /** @deprecated This method is no longer recommended. Use {@link #getLeaderboardUsersById} instead. */ @@ -754,9 +754,11 @@ public boolean updateLeaderboard(final Leaderboard leaderboard) { NamedPreparedStatement stmt = new NamedPreparedStatement(conn, sql)) { stmt.setString("name", leaderboard.getName()); stmt.setObject("createdAt", leaderboard.getCreatedAt()); - stmt.setObject("deletedAt", leaderboard.getDeletedAt()); + stmt.setObject("deletedAt", leaderboard.getDeletedAt().orElse(null)); stmt.setObject("id", UUID.fromString(leaderboard.getId())); - stmt.setString("syntaxHighlightingLanguage", leaderboard.getSyntaxHighlightingLanguage()); + stmt.setString( + "syntaxHighlightingLanguage", + leaderboard.getSyntaxHighlightingLanguage().orElse(null)); int rowsAffected = stmt.executeUpdate(); diff --git a/src/main/java/org/patinanetwork/codebloom/common/dto/leaderboard/LeaderboardDto.java b/src/main/java/org/patinanetwork/codebloom/common/dto/leaderboard/LeaderboardDto.java index dccc650b8..6f0c40ac8 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/dto/leaderboard/LeaderboardDto.java +++ b/src/main/java/org/patinanetwork/codebloom/common/dto/leaderboard/LeaderboardDto.java @@ -39,9 +39,10 @@ public static LeaderboardDto fromLeaderboard(final Leaderboard leaderboard) { .id(leaderboard.getId()) .name(leaderboard.getName()) .createdAt(leaderboard.getCreatedAt()) - .deletedAt(leaderboard.getDeletedAt()) - .shouldExpireBy(leaderboard.getShouldExpireBy()) - .syntaxHighlightingLanguage(leaderboard.getSyntaxHighlightingLanguage()) + .deletedAt(leaderboard.getDeletedAt().orElse(null)) + .shouldExpireBy(leaderboard.getShouldExpireBy().orElse(null)) + .syntaxHighlightingLanguage( + leaderboard.getSyntaxHighlightingLanguage().orElse(null)) .build(); } } diff --git a/src/main/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandler.java b/src/main/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandler.java index e011fdd49..a12725385 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandler.java +++ b/src/main/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandler.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Predicate; @@ -130,15 +131,15 @@ public ArrayList handleSubmissions( LeetcodeQuestion leetcodeQuestion = questionMap.get(leetcodeSubmission.getTitleSlug()); // If the submission is before the leaderboard started, points awarded = 0 - Leaderboard recentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + Optional recentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); // This should never be happening as there should always be an existing // leaderboard to fall on. Howerver, race conditions could trigger this problem. - if (recentLeaderboard == null) { + if (recentLeaderboard.isEmpty()) { throw new RuntimeException("No recent leaderboard found."); } - boolean isTooLate = recentLeaderboard.getCreatedAt().isAfter(leetcodeSubmission.getTimestamp()); + boolean isTooLate = recentLeaderboard.get().getCreatedAt().isAfter(leetcodeSubmission.getTimestamp()); // int basePoints = switch (leetcodeQuestion.getDifficulty().toUpperCase()) { // case "EASY" -> 100; @@ -210,10 +211,10 @@ public ArrayList handleSubmissions( new AcceptedSubmission(leetcodeQuestion.getQuestionTitle(), createdQuestion.getId(), points)); UserWithScore recentUserMetadata = userRepository.getUserWithScoreByIdAndLeaderboardId( - user.getId(), recentLeaderboard.getId(), UserFilterOptions.DEFAULT); + user.getId(), recentLeaderboard.get().getId(), UserFilterOptions.DEFAULT); leaderboardRepository.updateUserPointsFromLeaderboard( - recentLeaderboard.getId(), user.getId(), recentUserMetadata.getTotalScore() + points); + recentLeaderboard.get().getId(), user.getId(), recentUserMetadata.getTotalScore() + points); } return acceptedSubmissions; diff --git a/src/test/java/org/patinanetwork/codebloom/api/admin/AdminControllerTest.java b/src/test/java/org/patinanetwork/codebloom/api/admin/AdminControllerTest.java index dc079f10c..172558992 100644 --- a/src/test/java/org/patinanetwork/codebloom/api/admin/AdminControllerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/api/admin/AdminControllerTest.java @@ -77,7 +77,7 @@ void testCreateLeaderboardSuccessNoExistingLeaderboard() { NewLeaderboardBody body = NewLeaderboardBody.builder().name("Spring 2024 Challenge").build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -104,7 +104,7 @@ void testCreateLeaderboardSuccessWithExistingLeaderboard() { Leaderboard existingLeaderboard = Leaderboard.builder().id("existing-id").name("Old Leaderboard").build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(existingLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(existingLeaderboard)); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -183,7 +183,7 @@ void testCreateLeaderboardMaxValidName() { String maxName = "a".repeat(512); NewLeaderboardBody body = NewLeaderboardBody.builder().name(maxName).build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -203,7 +203,7 @@ void testCreateLeaderboardNameWithLeadingAndTrailingSpaces() { NewLeaderboardBody body = NewLeaderboardBody.builder().name(" Challenge 2024 ").build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -228,7 +228,7 @@ void testCreateLeaderboardWithShouldExpireBy() { .shouldExpireBy(futureDate) .build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -239,7 +239,8 @@ void testCreateLeaderboardWithShouldExpireBy() { verify(protector).validateAdminSession(request); verify(leaderboardRepository) - .addNewLeaderboard(argThat(leaderboard -> leaderboard.getShouldExpireBy() != null)); + .addNewLeaderboard( + argThat(leaderboard -> leaderboard.getShouldExpireBy().isPresent())); verify(leaderboardRepository).addAllUsersToLeaderboard(any()); } @@ -292,7 +293,7 @@ void testCreateLeaderboardWithSyntaxHighlightingLanguage() { .syntaxHighlightingLanguage("python") .build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -302,8 +303,10 @@ void testCreateLeaderboardWithSyntaxHighlightingLanguage() { assertEquals("Leaderboard was created successfully.", response.getBody().getMessage()); verify(protector).validateAdminSession(request); - verify(leaderboardRepository).addNewLeaderboard(argThat(leaderboard -> "python" - .equals(leaderboard.getSyntaxHighlightingLanguage()))); + verify(leaderboardRepository).addNewLeaderboard(argThat(leaderboard -> leaderboard + .getSyntaxHighlightingLanguage() + .filter("python"::equals) + .isPresent())); verify(leaderboardRepository).addAllUsersToLeaderboard(any()); } @@ -317,7 +320,7 @@ void testCreateLeaderboardWithAllOptionalFields() { .syntaxHighlightingLanguage("python") .build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -328,8 +331,12 @@ void testCreateLeaderboardWithAllOptionalFields() { verify(protector).validateAdminSession(request); verify(leaderboardRepository) - .addNewLeaderboard(argThat(leaderboard -> leaderboard.getShouldExpireBy() != null - && "python".equals(leaderboard.getSyntaxHighlightingLanguage()))); + .addNewLeaderboard( + argThat(leaderboard -> leaderboard.getShouldExpireBy().isPresent() + && leaderboard + .getSyntaxHighlightingLanguage() + .filter("python"::equals) + .isPresent())); verify(leaderboardRepository).addAllUsersToLeaderboard(any()); } @@ -341,7 +348,7 @@ void testCreateLeaderboardWithNullOptionalFields() { .syntaxHighlightingLanguage(null) .build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); ResponseEntity> response = adminController.createLeaderboard(request, body); @@ -352,8 +359,9 @@ void testCreateLeaderboardWithNullOptionalFields() { verify(protector).validateAdminSession(request); verify(leaderboardRepository) - .addNewLeaderboard(argThat(leaderboard -> leaderboard.getShouldExpireBy() == null - && leaderboard.getSyntaxHighlightingLanguage() == null)); + .addNewLeaderboard( + argThat(leaderboard -> leaderboard.getShouldExpireBy().isEmpty() + && leaderboard.getSyntaxHighlightingLanguage().isEmpty())); verify(leaderboardRepository).addAllUsersToLeaderboard(any()); } diff --git a/src/test/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandlerTest.java b/src/test/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandlerTest.java new file mode 100644 index 000000000..250c00d79 --- /dev/null +++ b/src/test/java/org/patinanetwork/codebloom/api/auth/security/CustomAuthenticationSuccessHandlerTest.java @@ -0,0 +1,409 @@ +package org.patinanetwork.codebloom.api.auth.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.codebloom.common.db.models.Session; +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.usertag.Tag; +import org.patinanetwork.codebloom.common.db.models.usertag.UserTag; +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.session.SessionRepository; +import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; +import org.patinanetwork.codebloom.common.db.repos.usertag.UserTagRepository; +import org.patinanetwork.codebloom.common.leetcode.models.UserProfile; +import org.patinanetwork.codebloom.common.leetcode.throttled.ThrottledLeetcodeClient; +import org.patinanetwork.codebloom.jda.client.JDAClient; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@DisplayName("CustomAuthenticationSuccessHandler") +class CustomAuthenticationSuccessHandlerTest { + + private final UserRepository userRepository = mock(UserRepository.class); + private final SessionRepository sessionRepository = mock(SessionRepository.class); + private final LeaderboardRepository leaderboardRepository = mock(LeaderboardRepository.class); + private final UserTagRepository userTagRepository = mock(UserTagRepository.class); + private final DiscordClubRepository discordClubRepository = mock(DiscordClubRepository.class); + private final JDAClient jdaClient = mock(JDAClient.class); + private final ThrottledLeetcodeClient leetcodeClient = mock(ThrottledLeetcodeClient.class); + + private final HttpServletRequest request = mock(HttpServletRequest.class); + private final HttpServletResponse response = mock(HttpServletResponse.class); + private final Authentication authentication = mock(Authentication.class); + + private final CustomAuthenticationSuccessHandler handler = new CustomAuthenticationSuccessHandler( + userRepository, + sessionRepository, + leaderboardRepository, + jdaClient, + userTagRepository, + discordClubRepository, + leetcodeClient); + + private static final String DISCORD_ID = "123456789"; + private static final String DISCORD_NAME = "testuser"; + + private final OAuth2User oAuth2User = mock(OAuth2User.class); + + { + when(oAuth2User.getAttributes()).thenReturn(Map.of("id", DISCORD_ID, "username", DISCORD_NAME)); + } + + @BeforeEach + void commonStubs() { + when(jdaClient.getGuilds()).thenReturn(List.of()); + when(discordClubRepository.getAllActiveDiscordClubs()).thenReturn(List.of()); + } + + @Test + @DisplayName("updates the discord name and creates a session cookie") + void updatesNameAndSetsCookie() throws Exception { + User existingUser = User.builder() + .id("user-1") + .discordId(DISCORD_ID) + .discordName("old-name") + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("session-abc"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertEquals(DISCORD_NAME, existingUser.getDiscordName()); + verify(userRepository).updateUser(existingUser); + + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookieCaptor.capture()); + Cookie cookie = cookieCaptor.getValue(); + assertEquals("session_token", cookie.getName()); + assertTrue(cookie.isHttpOnly()); + assertTrue(cookie.getSecure()); + + verify(response).sendRedirect("/dashboard"); + } + + @Test + @DisplayName("updates profile URL when the user has a leetcode username") + void updatesProfileUrl() throws Exception { + User existingUser = User.builder() + .id("user-1") + .discordId(DISCORD_ID) + .discordName("old-name") + .leetcodeUsername("leet_user") + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + UserProfile profile = new UserProfile("leet_user", "1000", "https://avatar.url", null, null); + when(leetcodeClient.getUserProfile("leet_user")).thenReturn(profile); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("session-xyz"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertEquals("https://avatar.url", existingUser.getProfileUrl()); + } + + @Test + @DisplayName("still succeeds if LeetCode profile lookup throws") + void survivesLeetcodeLookupFailure() throws Exception { + User existingUser = User.builder() + .id("user-1") + .discordId(DISCORD_ID) + .discordName("old-name") + .leetcodeUsername("bad_user") + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + when(leetcodeClient.getUserProfile("bad_user")).thenThrow(new RuntimeException("API down")); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("session-fail-safe"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + assertDoesNotThrow(() -> handler.onAuthenticationSuccess(request, response, authentication)); + verify(response).sendRedirect("/dashboard"); + } + + @Test + @DisplayName("creates a user, adds them to the leaderboard, and sets session cookie") + void createsUserAndAddsToLeaderboard() throws Exception { + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(null); + Leaderboard lb = Leaderboard.builder().id("lb-1").build(); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(lb)); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("new-session-id"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + handler.onAuthenticationSuccess(request, response, authentication); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).createUser(userCaptor.capture()); + var createdUser = userCaptor.getValue(); + assertEquals(DISCORD_ID, createdUser.getDiscordId()); + assertEquals(DISCORD_NAME, createdUser.getDiscordName()); + verify(leaderboardRepository).addUserToLeaderboard(any(), eq("lb-1")); + verify(response).sendRedirect("/dashboard"); + } + + @Test + @DisplayName("creates a tag when the user is a member of a guild linked to a club") + void assignsTagForGuildMember() throws Exception { + User existingUser = User.builder() + .id("user-club") + .discordId(DISCORD_ID) + .discordName(DISCORD_NAME) + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("s-club"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild-1"); + Member member = mock(Member.class); + when(member.getId()).thenReturn(DISCORD_ID); + when(guild.getMembers()).thenReturn(List.of(member)); + when(jdaClient.getGuilds()).thenReturn(List.of(guild)); + + DiscordClub club = DiscordClub.builder() + .name("Hunter College") + .tag(Tag.Hunter) + .discordClubMetadata(Optional.of(DiscordClubMetadata.builder() + .guildId(Optional.of("guild-1")) + .build())) + .build(); + when(discordClubRepository.getAllActiveDiscordClubs()).thenReturn(List.of(club)); + when(userTagRepository.findTagByUserIdAndTag("user-club", Tag.Hunter)).thenReturn(null); + + handler.onAuthenticationSuccess(request, response, authentication); + + ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(UserTag.class); + verify(userTagRepository).createTag(tagCaptor.capture()); + assertEquals(Tag.Hunter, tagCaptor.getValue().getTag()); + } + + @Test + @DisplayName("skips tag creation when user already has the tag") + void skipsExistingTag() throws Exception { + User existingUser = User.builder() + .id("user-club") + .discordId(DISCORD_ID) + .discordName(DISCORD_NAME) + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("s-club"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild-1"); + Member member = mock(Member.class); + when(member.getId()).thenReturn(DISCORD_ID); + when(guild.getMembers()).thenReturn(List.of(member)); + when(jdaClient.getGuilds()).thenReturn(List.of(guild)); + + DiscordClub club = DiscordClub.builder() + .name("Hunter College") + .tag(Tag.Hunter) + .discordClubMetadata(Optional.of(DiscordClubMetadata.builder() + .guildId(Optional.of("guild-1")) + .build())) + .build(); + when(discordClubRepository.getAllActiveDiscordClubs()).thenReturn(List.of(club)); + when(userTagRepository.findTagByUserIdAndTag("user-club", Tag.Hunter)) + .thenReturn(UserTag.builder().build()); + + handler.onAuthenticationSuccess(request, response, authentication); + + verify(userTagRepository, never()).createTag(any()); + } + + @Test + @DisplayName("sets nickname from guild member when club is Patina Network") + void setsNicknameForPatinaClub() throws Exception { + User existingUser = User.builder() + .id("user-club") + .discordId(DISCORD_ID) + .discordName(DISCORD_NAME) + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("s-club"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild-patina"); + Member member = mock(Member.class); + when(member.getId()).thenReturn(DISCORD_ID); + when(member.getNickname()).thenReturn("Cool Nickname"); + when(guild.getMembers()).thenReturn(List.of(member)); + when(jdaClient.getGuilds()).thenReturn(List.of(guild)); + + DiscordClub patinaClub = DiscordClub.builder() + .name("Patina Network") + .tag(Tag.Patina) + .discordClubMetadata(Optional.of(DiscordClubMetadata.builder() + .guildId(Optional.of("guild-patina")) + .build())) + .build(); + when(discordClubRepository.getAllActiveDiscordClubs()).thenReturn(List.of(patinaClub)); + when(userTagRepository.findTagByUserIdAndTag("user-club", Tag.Patina)).thenReturn(null); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertEquals("Cool Nickname", existingUser.getNickname()); + } + + @Test + @DisplayName("falls back to global name when guild nickname is null for Patina Network") + void fallsBackToGlobalNameForPatina() throws Exception { + User existingUser = User.builder() + .id("user-club") + .discordId(DISCORD_ID) + .discordName(DISCORD_NAME) + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("s-club"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild-patina"); + Member member = mock(Member.class); + when(member.getId()).thenReturn(DISCORD_ID); + when(member.getNickname()).thenReturn(null); + net.dv8tion.jda.api.entities.User discordUser = mock(net.dv8tion.jda.api.entities.User.class); + when(discordUser.getGlobalName()).thenReturn("Global Name"); + when(member.getUser()).thenReturn(discordUser); + when(guild.getMembers()).thenReturn(List.of(member)); + when(jdaClient.getGuilds()).thenReturn(List.of(guild)); + + DiscordClub patinaClub = DiscordClub.builder() + .name("Patina Network") + .tag(Tag.Patina) + .discordClubMetadata(Optional.of(DiscordClubMetadata.builder() + .guildId(Optional.of("guild-patina")) + .build())) + .build(); + when(discordClubRepository.getAllActiveDiscordClubs()).thenReturn(List.of(patinaClub)); + when(userTagRepository.findTagByUserIdAndTag("user-club", Tag.Patina)).thenReturn(null); + + handler.onAuthenticationSuccess(request, response, authentication); + + assertEquals("Global Name", existingUser.getNickname()); + } + + @Test + @DisplayName("skips clubs whose metadata has no guildId") + void skipsClubWithNoGuildId() throws Exception { + User existingUser = User.builder() + .id("user-club") + .discordId(DISCORD_ID) + .discordName(DISCORD_NAME) + .build(); + when(authentication.getPrincipal()).thenReturn(oAuth2User); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + doAnswer(inv -> { + Session s = inv.getArgument(0); + s.setId("s-club"); + return null; + }) + .when(sessionRepository) + .createSession(any(Session.class)); + + DiscordClub clubNoGuild = DiscordClub.builder() + .name("No Guild Club") + .tag(Tag.Nyu) + .discordClubMetadata(Optional.of( + DiscordClubMetadata.builder().guildId(Optional.empty()).build())) + .build(); + when(discordClubRepository.getAllActiveDiscordClubs()).thenReturn(List.of(clubNoGuild)); + + handler.onAuthenticationSuccess(request, response, authentication); + + verify(userTagRepository, never()).createTag(any()); + } + + @Test + @DisplayName("redirects and throws when the principal is not an OAuth2User") + void nonOAuthPrincipalFails() { + when(authentication.getPrincipal()).thenReturn("not-an-oauth-user"); + + assertThrows( + UsernameNotFoundException.class, + () -> handler.onAuthenticationSuccess(request, response, authentication)); + } + + @Test + @DisplayName("redirects and throws when session creation returns a null ID") + void sessionCreationFailureRedirects() throws Exception { + when(authentication.getPrincipal()).thenReturn(oAuth2User); + var existingUser = User.builder() + .id("user-fail") + .discordId(DISCORD_ID) + .discordName(DISCORD_NAME) + .build(); + when(userRepository.getUserByDiscordId(DISCORD_ID)).thenReturn(existingUser); + + doNothing().when(sessionRepository).createSession(any(Session.class)); + + assertThrows(RuntimeException.class, () -> handler.onAuthenticationSuccess(request, response, authentication)); + } +} diff --git a/src/test/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardControllerTest.java b/src/test/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardControllerTest.java index 3559a2519..d5c084e1a 100644 --- a/src/test/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardControllerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/api/leaderboard/LeaderboardControllerTest.java @@ -3,17 +3,34 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.patinanetwork.codebloom.common.components.LeaderboardManager; 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.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.dto.ApiResponder; +import org.patinanetwork.codebloom.common.dto.leaderboard.LeaderboardDto; +import org.patinanetwork.codebloom.common.dto.user.UserWithScoreDto; +import org.patinanetwork.codebloom.common.page.Indexed; +import org.patinanetwork.codebloom.common.page.Page; +import org.patinanetwork.codebloom.common.security.AuthenticationObject; import org.patinanetwork.codebloom.common.security.Protector; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; +@DisplayName("LeaderboardController") public class LeaderboardControllerTest { private static final int PAGE_SIZE = 20; private static final int PAGE = 1; @@ -72,7 +89,7 @@ void testGetLeaderboardUsersByIdMhcPlusPlus() { @Test void testCurrentLeaderboardUsersMhcPlusPlus() { when(leaderboardRepository.getRecentLeaderboardMetadata()) - .thenReturn(Leaderboard.builder().id(LEADERBOARD_ID).build()); + .thenReturn(Optional.of(Leaderboard.builder().id(LEADERBOARD_ID).build())); leaderboardController.getCurrentLeaderboardUsers( null, PAGE, PAGE_SIZE, "", false, false, false, false, false, false, false, false, false, false, false, @@ -87,4 +104,381 @@ void testCurrentLeaderboardUsersMhcPlusPlus() { assertEquals(PAGE_SIZE, opts.getPageSize()); assertEquals(PAGE, opts.getPage()); } + + @Test + @DisplayName("returns 200 with metadata when leaderboard exists") + void returnsMetadata() { + Leaderboard lb = Leaderboard.builder() + .id(LEADERBOARD_ID) + .name("Season 1") + .createdAt(LocalDateTime.of(2025, 1, 1, 0, 0)) + .build(); + when(leaderboardManager.getLeaderboardMetadata(LEADERBOARD_ID)).thenReturn(Optional.of(lb)); + + ResponseEntity> response = + leaderboardController.getLeaderboardMetadataByLeaderboardId(LEADERBOARD_ID, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().isSuccess()); + assertEquals("Season 1", response.getBody().getPayload().getName()); + } + + @Test + @DisplayName("throws 404 when leaderboard does not exist") + void throws404WhenNotFound() { + when(leaderboardManager.getLeaderboardMetadata("missing-id")).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> leaderboardController.getLeaderboardMetadataByLeaderboardId("missing-id", null)); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + + @Test + @DisplayName("caps page size to MAX_LEADERBOARD_PAGE_SIZE (20)") + void capsPageSize() { + leaderboardController.getLeaderboardUsersById( + LEADERBOARD_ID, + 1, + 999, + "", + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LeaderboardFilterOptions.class); + verify(leaderboardManager).getLeaderboardUsers(eq(LEADERBOARD_ID), captor.capture(), eq(false)); + + assertEquals(PAGE_SIZE, captor.getValue().getPageSize()); + } + + @Test + @DisplayName("passes all filter flags through to LeaderboardFilterOptions") + void passesAllFilters() { + leaderboardController.getLeaderboardUsersById( + LEADERBOARD_ID, + 2, + 10, + "tahmid", + true /* patina */, + true /* hunter */, + true /* nyu */, + true /* baruch */, + true /* rpi */, + true /* gwc */, + true /* sbu */, + true /* ccny */, + true /* columbia */, + true /* cornell */, + true /* bmcc */, + true /* mhc++ */, + true /* globalIndex */, + null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LeaderboardFilterOptions.class); + verify(leaderboardManager).getLeaderboardUsers(eq(LEADERBOARD_ID), captor.capture(), eq(true)); + + var opts = captor.getValue(); + assertEquals(2, opts.getPage()); + assertEquals(10, opts.getPageSize()); + assertEquals("tahmid", opts.getQuery()); + assertTrue(opts.isPatina()); + assertTrue(opts.isHunter()); + assertTrue(opts.isNyu()); + assertTrue(opts.isBaruch()); + assertTrue(opts.isRpi()); + assertTrue(opts.isGwc()); + assertTrue(opts.isSbu()); + assertTrue(opts.isCcny()); + assertTrue(opts.isColumbia()); + assertTrue(opts.isCornell()); + assertTrue(opts.isBmcc()); + assertTrue(opts.isMhcplusplus()); + } + + @Test + @DisplayName("returns metadata for the most recent leaderboard") + void returnsCurrentMetadata() { + Leaderboard lb = Leaderboard.builder() + .id(LEADERBOARD_ID) + .name("Current Season") + .createdAt(LocalDateTime.of(2025, 6, 1, 0, 0)) + .build(); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(lb)); + when(leaderboardManager.getLeaderboardMetadata(LEADERBOARD_ID)).thenReturn(Optional.of(lb)); + + ResponseEntity> response = + leaderboardController.getCurrentLeaderboardMetadata(null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Current Season", response.getBody().getPayload().getName()); + } + + @Test + @DisplayName("throws 404 when there is no active leaderboard") + void throws404WhenNoActive() { + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); + + assertThrows(ResponseStatusException.class, () -> leaderboardController.getCurrentLeaderboardMetadata(null)); + } + + @Test + @DisplayName("throws 404 when manager can't find the active leaderboard by id") + void throws404WhenManagerReturnsEmpty() { + Leaderboard lb = Leaderboard.builder().id("lb-ghost").build(); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(lb)); + when(leaderboardManager.getLeaderboardMetadata("lb-ghost")).thenReturn(Optional.empty()); + + assertThrows(ResponseStatusException.class, () -> leaderboardController.getCurrentLeaderboardMetadata(null)); + } + + @Test + @DisplayName("delegates to leaderboardManager with current leaderboard ID") + void delegatesToManager() { + when(leaderboardRepository.getRecentLeaderboardMetadata()) + .thenReturn(Optional.of(Leaderboard.builder().id(LEADERBOARD_ID).build())); + + Page> fakePage = new Page<>(false, List.of(), 1, 20); + when(leaderboardManager.getLeaderboardUsers(eq(LEADERBOARD_ID), any(), eq(false))) + .thenReturn(fakePage); + + ResponseEntity>>> response = + leaderboardController.getCurrentLeaderboardUsers( + null, 1, 20, "", false, false, false, false, false, false, false, false, false, false, false, + false, false); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().isSuccess()); + } + + @Test + @DisplayName("throws 404 when no current leaderboard exists for user list") + void throws404WhenNoLeaderboardForUsers() { + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); + + assertThrows( + ResponseStatusException.class, + () -> leaderboardController.getCurrentLeaderboardUsers( + null, 1, 20, "", false, false, false, false, false, false, false, false, false, false, false, + false, false)); + } + + @Test + @DisplayName("returns the user's data from the active leaderboard") + void returnsUserData() { + Leaderboard lb = Leaderboard.builder().id(LEADERBOARD_ID).build(); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(lb)); + + UserWithScore uws = UserWithScore.builder() + .id("u1") + .discordId("d1") + .discordName("testuser") + .totalScore(500) + .build(); + when(userRepository.getUserWithScoreByIdAndLeaderboardId(eq("u1"), eq(LEADERBOARD_ID), any())) + .thenReturn(uws); + + ResponseEntity> response = + leaderboardController.getUserCurrentLeaderboardFull(null, "u1"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("testuser", response.getBody().getPayload().getDiscordName()); + assertEquals(500, response.getBody().getPayload().getTotalScore()); + } + + @Test + @DisplayName("throws 404 when no active leaderboard exists for user lookup") + void throws404WhenNoLeaderboardForUser() { + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); + + assertThrows( + ResponseStatusException.class, () -> leaderboardController.getUserCurrentLeaderboardFull(null, "u1")); + } + + @Test + @DisplayName("returns global rank when no filters are applied") + void returnsGlobalRank() { + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + User authUser = User.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .build(); + when(protector.validateSession(mockRequest)).thenReturn(new AuthenticationObject(authUser, null)); + when(leaderboardRepository.getRecentLeaderboardMetadata()) + .thenReturn(Optional.of(Leaderboard.builder().id(LEADERBOARD_ID).build())); + + UserWithScore uws = UserWithScore.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .totalScore(1000) + .build(); + Indexed indexed = Indexed.of(uws, 3); + when(leaderboardRepository.getGlobalRankedUserById(LEADERBOARD_ID, "u-rank")) + .thenReturn(Optional.of(indexed)); + + var response = leaderboardController.getUserCurrentLeaderboardRank( + mockRequest, false, false, false, false, false, false, false, false, false, false, false); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(3, response.getBody().getPayload().getIndex()); + assertEquals("ranker", response.getBody().getPayload().getItem().getDiscordName()); + } + + @Test + @DisplayName("returns filtered rank when filter flags are applied") + void returnsFilteredRank() { + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + User authUser = User.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .build(); + when(protector.validateSession(mockRequest)).thenReturn(new AuthenticationObject(authUser, null)); + when(leaderboardRepository.getRecentLeaderboardMetadata()) + .thenReturn(Optional.of(Leaderboard.builder().id(LEADERBOARD_ID).build())); + + UserWithScore uws = UserWithScore.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .totalScore(800) + .build(); + Indexed indexed = Indexed.of(uws, 1); + when(leaderboardRepository.getFilteredRankedUserById(eq(LEADERBOARD_ID), eq("u-rank"), any())) + .thenReturn(Optional.of(indexed)); + + var response = leaderboardController.getUserCurrentLeaderboardRank( + mockRequest, true, false, false, false, false, false, false, false, false, false, false); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(1, response.getBody().getPayload().getIndex()); + } + + @Test + @DisplayName("throws 404 when user is not found on the leaderboard (global)") + void throws404GlobalNotFound() { + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + User authUser = User.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .build(); + when(protector.validateSession(mockRequest)).thenReturn(new AuthenticationObject(authUser, null)); + when(leaderboardRepository.getRecentLeaderboardMetadata()) + .thenReturn(Optional.of(Leaderboard.builder().id(LEADERBOARD_ID).build())); + + when(leaderboardRepository.getGlobalRankedUserById(LEADERBOARD_ID, "u-rank")) + .thenReturn(Optional.empty()); + + assertThrows( + ResponseStatusException.class, + () -> leaderboardController.getUserCurrentLeaderboardRank( + mockRequest, false, false, false, false, false, false, false, false, false, false, false)); + } + + @Test + @DisplayName("throws 404 when user is not found with filters") + void throws404FilteredNotFound() { + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + User authUser = User.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .build(); + when(protector.validateSession(mockRequest)).thenReturn(new AuthenticationObject(authUser, null)); + when(leaderboardRepository.getRecentLeaderboardMetadata()) + .thenReturn(Optional.of(Leaderboard.builder().id(LEADERBOARD_ID).build())); + + when(leaderboardRepository.getFilteredRankedUserById(eq(LEADERBOARD_ID), eq("u-rank"), any())) + .thenReturn(Optional.empty()); + + assertThrows( + ResponseStatusException.class, + () -> leaderboardController.getUserCurrentLeaderboardRank( + mockRequest, true, false, false, false, false, false, false, false, false, false, false)); + } + + @Test + @DisplayName("throws 404 when no active leaderboard exists for rank lookup") + void throws404NoLeaderboardForRank() { + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + User authUser = User.builder() + .id("u-rank") + .discordId("d1") + .discordName("ranker") + .build(); + when(protector.validateSession(mockRequest)).thenReturn(new AuthenticationObject(authUser, null)); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); + + assertThrows( + ResponseStatusException.class, + () -> leaderboardController.getUserCurrentLeaderboardRank( + mockRequest, false, false, false, false, false, false, false, false, false, false, false)); + } + + @Test + @DisplayName("returns a page of all leaderboard metadata") + void returnsAllMetadata() { + Leaderboard lb1 = Leaderboard.builder() + .id("lb-1") + .name("Season 1") + .createdAt(LocalDateTime.of(2025, 1, 1, 0, 0)) + .build(); + Leaderboard lb2 = Leaderboard.builder() + .id("lb-2") + .name("Season 2") + .createdAt(LocalDateTime.of(2025, 6, 1, 0, 0)) + .build(); + when(leaderboardRepository.getAllLeaderboardsShallow(any())).thenReturn(List.of(lb1, lb2)); + when(leaderboardRepository.getLeaderboardCount()).thenReturn(2); + + var response = leaderboardController.getAllLeaderboardMetadata(null, 1, "", 20); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(2, response.getBody().getPayload().getItems().size()); + assertFalse(response.getBody().getPayload().isHasNextPage()); + } + + @Test + @DisplayName("reports hasNextPage=true when there are more pages") + void hasNextPageWhenMoreExist() { + when(leaderboardRepository.getAllLeaderboardsShallow(any())) + .thenReturn(List.of(Leaderboard.builder() + .id("lb-1") + .name("S1") + .createdAt(LocalDateTime.of(2025, 1, 1, 0, 0)) + .build())); + when(leaderboardRepository.getLeaderboardCount()).thenReturn(25); + + var response = leaderboardController.getAllLeaderboardMetadata(null, 1, "", 10); + + assertTrue(response.getBody().getPayload().isHasNextPage()); + assertEquals(3, response.getBody().getPayload().getPages()); + } + + @Test + @DisplayName("caps page size at MAX_LEADERBOARD_PAGE_SIZE for all metadata") + void capsAllMetadataPageSize() { + when(leaderboardRepository.getAllLeaderboardsShallow(any())).thenReturn(List.of()); + when(leaderboardRepository.getLeaderboardCount()).thenReturn(0); + + leaderboardController.getAllLeaderboardMetadata(null, 1, "", 999); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LeaderboardFilterOptions.class); + verify(leaderboardRepository).getAllLeaderboardsShallow(captor.capture()); + assertEquals(20, captor.getValue().getPageSize()); + } } 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..b1eac47c8 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java @@ -411,8 +411,10 @@ private void setupMockLeaderboardData() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); + when(mockLeaderboard.getShouldExpireBy()) + .thenReturn(Optional.of(LocalDateTime.now().plusDays(7))); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); when(leaderboardRepository.getLeaderboardUserCountById(eq("leaderboard-id"), any())) .thenReturn(25); @@ -432,9 +434,10 @@ private void setupMockLeaderboardDataWithExpiration() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(mockLeaderboard.getShouldExpireBy()).thenReturn(LocalDateTime.now().plusDays(7)); + when(mockLeaderboard.getShouldExpireBy()) + .thenReturn(Optional.of(LocalDateTime.now().plusDays(7))); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); } private void setupMockLeaderboardDataWithoutExpiration() { @@ -443,9 +446,9 @@ private void setupMockLeaderboardDataWithoutExpiration() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(mockLeaderboard.getShouldExpireBy()).thenReturn(null); + when(mockLeaderboard.getShouldExpireBy()).thenReturn(Optional.empty()); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); } private List createMockUsers() { @@ -505,7 +508,7 @@ private void setupMockLeaderboardDataWithMixedScoreUsers() { when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); List mockUsers = createMockUsersWithMixedScores(); when(leaderboardRepository.getLeaderboardUsersById(eq("leaderboard-id"), any(LeaderboardFilterOptions.class))) @@ -519,7 +522,7 @@ private void setupMockLeaderboardDataWithAllZeroScoreUsers() { when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); List mockUsers = createMockUsersAllZeroScores(); when(leaderboardRepository.getLeaderboardUsersById(eq("leaderboard-id"), any(LeaderboardFilterOptions.class))) @@ -534,9 +537,10 @@ private void setupMockLeaderboardDataWithExpirationAndMixedScoreUsers() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(mockLeaderboard.getShouldExpireBy()).thenReturn(LocalDateTime.now().plusDays(7)); + when(mockLeaderboard.getShouldExpireBy()) + .thenReturn(Optional.of(LocalDateTime.now().plusDays(7))); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); } private void setupMockLeaderboardDataWithExpirationAndAllZeroScoreUsers() { @@ -545,9 +549,10 @@ private void setupMockLeaderboardDataWithExpirationAndAllZeroScoreUsers() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(mockLeaderboard.getShouldExpireBy()).thenReturn(LocalDateTime.now().plusDays(7)); + when(mockLeaderboard.getShouldExpireBy()) + .thenReturn(Optional.of(LocalDateTime.now().plusDays(7))); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); } private List createMockUsersOneUser() { @@ -575,7 +580,7 @@ private void setupMockLeaderboardDataWithOneUser() { when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); List mockUsers = createMockUsersOneUser(); when(leaderboardRepository.getLeaderboardUsersById(eq("leaderboard-id"), any(LeaderboardFilterOptions.class))) @@ -589,7 +594,7 @@ private void setupMockLeaderboardDataWithTwoUsers() { when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); List mockUsers = createMockUsersTwoUsers(); when(leaderboardRepository.getLeaderboardUsersById(eq("leaderboard-id"), any(LeaderboardFilterOptions.class))) @@ -604,9 +609,10 @@ private void setupMockLeaderboardDataWithExpirationAndOneUser() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(mockLeaderboard.getShouldExpireBy()).thenReturn(LocalDateTime.now().plusDays(7)); + when(mockLeaderboard.getShouldExpireBy()) + .thenReturn(Optional.of(LocalDateTime.now().plusDays(7))); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); } private void setupMockLeaderboardDataWithExpirationAndTwoUsers() { @@ -615,8 +621,9 @@ private void setupMockLeaderboardDataWithExpirationAndTwoUsers() { Leaderboard mockLeaderboard = mock(Leaderboard.class); when(mockLeaderboard.getId()).thenReturn("leaderboard-id"); when(mockLeaderboard.getName()).thenReturn("Test Leaderboard"); - when(mockLeaderboard.getShouldExpireBy()).thenReturn(LocalDateTime.now().plusDays(7)); + when(mockLeaderboard.getShouldExpireBy()) + .thenReturn(Optional.of(LocalDateTime.now().plusDays(7))); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(mockLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(mockLeaderboard)); } } 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..da3b68cf1 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/components/LeaderboardManagerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/components/LeaderboardManagerTest.java @@ -8,6 +8,7 @@ import com.github.javafaker.Faker; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -106,7 +107,7 @@ void testGetLeaderboardUsersDelegation() { @Test void testWithNoAvailableLeaderboard() { - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(null); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); leaderboardManager.generateAchievementsForAllWinners(); @@ -123,7 +124,7 @@ void testWithAvailableLeaderboardButNoUsers() { .createdAt(StandardizedLocalDateTime.now()) .build(); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(List.of()); @@ -146,7 +147,7 @@ void testWithAvailableLeaderboardAndTwoWinnersGlobal() { randomPartialUserWithScore().totalScore(150_000).build(), randomPartialUserWithScore().totalScore(70_000).build())); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -194,7 +195,7 @@ void testWithAvailableLeaderboardAndTwoWinnersGlobalWithOneValidTag() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -264,7 +265,7 @@ void testWithAvailableLeaderboardAndTwoWinnersGlobalWithTwoValidTags() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -351,7 +352,7 @@ void testWithAvailableLeaderboardAndTwoWinnersGlobalWithOneValidOneInvalidTag() .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -432,7 +433,7 @@ void testWithAvailableLeaderboardAndTwoWinnersWithOnlyGwcTag() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -502,7 +503,7 @@ void testWithAvailableLeaderboardAndTwoWinnersWithGwcAndMhcPlusPlusTags() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -583,7 +584,7 @@ void testGwcUsersWithZeroPointsAreExcludedFromTagAchievements() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(users); @@ -610,7 +611,7 @@ void testWithAvailableLeaderboardAndThreeWinnersGlobalButFourUsers() { randomPartialUserWithScore().totalScore(30_000).build(), randomPartialUserWithScore().totalScore(29_999).build())); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -651,7 +652,7 @@ void testUsersWithZeroPointsAreExcludedFromAchievements() { randomPartialUserWithScore().totalScore(0).build(), randomPartialUserWithScore().totalScore(0).build())); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(users); @@ -690,7 +691,7 @@ void testUsersWithZeroPointsAreExcludedFromTagAchievements() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(users); @@ -715,7 +716,7 @@ void testWinnersWithNullTagsAreSkippedInTagLeaderboards() { randomPartialUserWithScore().totalScore(150_000).build(), randomPartialUserWithScore().totalScore(70_000).build())); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -751,7 +752,7 @@ void testWinnersWithNonMatchingTagsAreSkippedInTagLeaderboards() { .build())); }); - when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(latestLeaderboard); + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(latestLeaderboard)); when(leaderboardRepository.getGlobalRankedIndexedLeaderboardUsersById(eq(latestLeaderboard.getId()), any())) .thenReturn(winners); @@ -800,9 +801,10 @@ void testGetLeaderboardMetadata() { .createdAt(StandardizedLocalDateTime.now()) .build(); - when(leaderboardRepository.getLeaderboardMetadataById(testId)).thenReturn(leaderboard); + when(leaderboardRepository.getLeaderboardMetadataById(testId)).thenReturn(Optional.of(leaderboard)); - Leaderboard leaderboardData = leaderboardManager.getLeaderboardMetadata(testId); + Leaderboard leaderboardData = + leaderboardManager.getLeaderboardMetadata(testId).get(); assertNotNull(leaderboardData); assertEquals(leaderboard.getId(), leaderboardData.getId()); diff --git a/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryRankTest.java b/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryRankTest.java index ad99c1817..16aa58804 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryRankTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryRankTest.java @@ -32,7 +32,8 @@ public class LeaderboardRepositoryRankTest extends BaseRepositoryTest { @Autowired public LeaderboardRepositoryRankTest(final LeaderboardRepository leaderboardRepository) { this.leaderboardRepository = leaderboardRepository; - this.currentLeaderboard = this.leaderboardRepository.getRecentLeaderboardMetadata(); + this.currentLeaderboard = + this.leaderboardRepository.getRecentLeaderboardMetadata().get(); } @Test @@ -46,9 +47,9 @@ void assertRankedConsistencyBetweenUserListAndSingleUserFiltered() { users.forEach(i -> { var indexed = leaderboardRepository.getFilteredRankedUserById( currentLeaderboard.getId(), i.getItem().getId(), opts); - var user = indexed.getItem(); + var user = indexed.get().getItem(); assertEquals( - indexed.getIndex(), + indexed.get().getIndex(), users.stream() .filter(u -> { var possibleUser = u.getItem(); @@ -70,9 +71,9 @@ void assertRankedConsistencyBetweenUserListAndSingleUserGlobal() { users.forEach(i -> { var indexed = leaderboardRepository.getGlobalRankedUserById( currentLeaderboard.getId(), i.getItem().getId()); - var user = indexed.getItem(); + var user = indexed.get().getItem(); assertEquals( - indexed.getIndex(), + indexed.get().getIndex(), users.stream() .filter(u -> { var possibleUser = u.getItem(); @@ -90,9 +91,8 @@ void assertGetFilteredRankedUserByIdFailsIfNotInSearchCriteria() { String gwcUserId = "a1a1a1a1-a2d2-e3e3-f4f4-a5a5a5a5a5a5"; var opts = LeaderboardFilterOptions.builder().patina(true).build(); - Indexed i = - leaderboardRepository.getFilteredRankedUserById(currentLeaderboard.getId(), gwcUserId, opts); - assertNull(i); + var result = leaderboardRepository.getFilteredRankedUserById(currentLeaderboard.getId(), gwcUserId, opts); + assertTrue(result.isEmpty()); } @Test @@ -156,7 +156,10 @@ void assertMultipleTagsFiltering() { assertNotNull(patinaResult, "User with Patina tag should appear in Patina filter"); assertNotNull(baruchResult, "User with Baruch tag should appear in Baruch filter"); - assertEquals(patinaResult.getItem(), baruchResult.getItem(), "Same user should be returned in both filters"); + assertEquals( + patinaResult.get().getItem(), + baruchResult.get().getItem(), + "Same user should be returned in both filters"); } @Test @@ -173,8 +176,8 @@ void assertUserTagCreationDateFiltering() { var newTagResult = leaderboardRepository.getFilteredRankedUserById(EXPIRED_LEADERBOARD_ID, userWithNewTag, hunterOpts); - assertNotNull(oldTagResult, "User with tag created before leaderboard deletion should be included"); - assertNull(newTagResult, "User with tag created after leaderboard deletion should be excluded"); + assertTrue(oldTagResult.isPresent(), "User with tag created before leaderboard deletion should be included"); + assertTrue(newTagResult.isEmpty(), "User with tag created after leaderboard deletion should be excluded"); } @Test diff --git a/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryTest.java b/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryTest.java index 0b7156971..4eaed13cd 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/db/repos/leaderboard/LeaderboardRepositoryTest.java @@ -7,6 +7,7 @@ import java.lang.reflect.Method; import java.util.List; +import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.IntStream; import lombok.extern.slf4j.Slf4j; @@ -51,8 +52,14 @@ public LeaderboardRepositoryTest( @BeforeAll void createMockLeaderboard() { // will bring this back after - previousLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); - mockLeaderboard = Leaderboard.builder().name("Mock Leaderboard").build(); + previousLeaderboard = + leaderboardRepository.getRecentLeaderboardMetadata().get(); + mockLeaderboard = Leaderboard.builder() + .name("Mock Leaderboard") + .deletedAt(Optional.empty()) + .shouldExpireBy(Optional.empty()) + .syntaxHighlightingLanguage(Optional.empty()) + .build(); leaderboardRepository.addNewLeaderboard(mockLeaderboard); } @@ -80,7 +87,8 @@ void deleteMockLeaderboard() throws Exception { @Order(1) @Test void testIfMostRecentLeaderboardMetadataValid() { - Leaderboard possiblyMockLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata(); + Leaderboard possiblyMockLeaderboard = + leaderboardRepository.getRecentLeaderboardMetadata().get(); if (possiblyMockLeaderboard == null || !mockLeaderboard.equals(possiblyMockLeaderboard)) { log.info("[DEBUG] - mockLeaderboard: {}", mockLeaderboard.toString()); @@ -94,7 +102,9 @@ void testIfMostRecentLeaderboardMetadataValid() { @Order(2) @Test void testGetLeaderboardMetadataById() { - Leaderboard possiblyMockLeaderboard = leaderboardRepository.getLeaderboardMetadataById(mockLeaderboard.getId()); + Leaderboard possiblyMockLeaderboard = leaderboardRepository + .getLeaderboardMetadataById(mockLeaderboard.getId()) + .get(); if (possiblyMockLeaderboard == null || !mockLeaderboard.equals(possiblyMockLeaderboard)) { log.info("[DEBUG] - mockLeaderboard: {}", mockLeaderboard.toString()); @@ -113,7 +123,9 @@ void testUpdateLeaderboardName() { // ensure that they are still equal - Leaderboard possiblyMockLeaderboard = leaderboardRepository.getLeaderboardMetadataById(mockLeaderboard.getId()); + Leaderboard possiblyMockLeaderboard = leaderboardRepository + .getLeaderboardMetadataById(mockLeaderboard.getId()) + .get(); if (possiblyMockLeaderboard == null || !mockLeaderboard.equals(possiblyMockLeaderboard)) { log.info("[DEBUG] - mockLeaderboard: {}", mockLeaderboard.toString()); diff --git a/src/test/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandlerTest.java b/src/test/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandlerTest.java new file mode 100644 index 000000000..8e0411500 --- /dev/null +++ b/src/test/java/org/patinanetwork/codebloom/common/submissions/SubmissionsHandlerTest.java @@ -0,0 +1,310 @@ +package org.patinanetwork.codebloom.common.submissions; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.codebloom.common.db.models.job.Job; +import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; +import org.patinanetwork.codebloom.common.db.models.potd.POTD; +import org.patinanetwork.codebloom.common.db.models.question.Question; +import org.patinanetwork.codebloom.common.db.models.question.QuestionDifficulty; +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.job.JobRepository; +import org.patinanetwork.codebloom.common.db.repos.leaderboard.LeaderboardRepository; +import org.patinanetwork.codebloom.common.db.repos.potd.POTDRepository; +import org.patinanetwork.codebloom.common.db.repos.question.QuestionRepository; +import org.patinanetwork.codebloom.common.db.repos.question.topic.QuestionTopicRepository; +import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; +import org.patinanetwork.codebloom.common.leetcode.models.LeetcodeQuestion; +import org.patinanetwork.codebloom.common.leetcode.models.LeetcodeSubmission; +import org.patinanetwork.codebloom.common.leetcode.models.LeetcodeTopicTag; +import org.patinanetwork.codebloom.common.leetcode.throttled.ThrottledLeetcodeClient; +import org.patinanetwork.codebloom.common.reporter.throttled.ThrottledReporter; +import org.patinanetwork.codebloom.common.submissions.object.AcceptedSubmission; + +@DisplayName("SubmissionsHandler") +class SubmissionsHandlerTest { + private final QuestionRepository questionRepository = mock(QuestionRepository.class); + private final ThrottledLeetcodeClient leetcodeClient = mock(ThrottledLeetcodeClient.class); + private final LeaderboardRepository leaderboardRepository = mock(LeaderboardRepository.class); + private final POTDRepository potdRepository = mock(POTDRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + private final QuestionTopicRepository questionTopicRepository = mock(QuestionTopicRepository.class); + private final ThrottledReporter throttledReporter = mock(ThrottledReporter.class); + private final JobRepository jobRepository = mock(JobRepository.class); + + private final SubmissionsHandler handler = new SubmissionsHandler( + questionRepository, + leetcodeClient, + leaderboardRepository, + potdRepository, + userRepository, + questionTopicRepository, + throttledReporter, + jobRepository); + + private static final String USER_ID = "user-42"; + private static final String LEADERBOARD_ID = "lb-99"; + private final User user = User.builder() + .id(USER_ID) + .discordId("d123") + .discordName("tester") + .leetcodeUsername("leet_tester") + .build(); + + private final Leaderboard leaderboard = Leaderboard.builder() + .id(LEADERBOARD_ID) + .createdAt(LocalDateTime.of(2025, 1, 1, 0, 0)) + .build(); + + private LeetcodeSubmission acceptedSubmission(int id, String slug, LocalDateTime timestamp) { + return new LeetcodeSubmission(id, slug, slug, timestamp, "Accepted"); + } + + private LeetcodeQuestion leetcodeQuestion(String slug, String difficulty, float acceptanceRate) { + return LeetcodeQuestion.builder() + .questionId(1) + .questionTitle(slug) + .titleSlug(slug) + .difficulty(difficulty) + .acceptanceRate(acceptanceRate) + .question("Description of " + slug) + .topics(List.of( + LeetcodeTopicTag.builder().name("Array").slug("array").build())) + .build(); + } + + @BeforeEach + void commonStubs() { + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.of(leaderboard)); + when(potdRepository.getCurrentPOTD()).thenReturn(null); + + when(questionRepository.createQuestion(any(Question.class))).thenAnswer(inv -> { + Question q = inv.getArgument(0); + q.setId("q-new"); + return q; + }); + + when(userRepository.getUserWithScoreByIdAndLeaderboardId(eq(USER_ID), eq(LEADERBOARD_ID), any())) + .thenReturn(UserWithScore.builder() + .id(USER_ID) + .discordId("d123") + .discordName("tester") + .totalScore(0) + .build()); + } + + @Test + @DisplayName("ignores submissions that are not 'Accepted'") + void skipsNonAcceptedSubmissions() { + LeetcodeSubmission rejected = + new LeetcodeSubmission(1, "two-sum", "two-sum", LocalDateTime.now(), "Wrong Answer"); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + ArrayList result = handler.handleSubmissions(List.of(rejected), user, false); + + assertTrue(result.isEmpty()); + verify(questionRepository, never()).createQuestion(any()); + } + + @Test + @DisplayName("ignores submissions that already exist (by submission ID)") + void skipsDuplicateSubmissions() { + LeetcodeSubmission sub = acceptedSubmission(100, "two-sum", LocalDateTime.now()); + when(questionRepository.questionExistsBySubmissionId("100")).thenReturn(true); + + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + ArrayList result = handler.handleSubmissions(List.of(sub), user, false); + + assertTrue(result.isEmpty()); + verify(questionRepository, never()).createQuestion(any()); + } + + @Test + @DisplayName("creates a question in the database for a new accepted submission") + void createsQuestion() { + LeetcodeSubmission submission = acceptedSubmission(200, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("200")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + handler.handleSubmissions(List.of(submission), user, false); + + ArgumentCaptor qCaptor = ArgumentCaptor.forClass(Question.class); + verify(questionRepository).createQuestion(qCaptor.capture()); + + Question created = qCaptor.getValue(); + assertEquals("two-sum", created.getQuestionSlug()); + assertEquals(QuestionDifficulty.Easy, created.getQuestionDifficulty()); + assertEquals(USER_ID, created.getUserId()); + } + + @Test + @DisplayName("creates a job for a new accepted submission") + void createsJob() { + LeetcodeSubmission submission = acceptedSubmission(200, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("200")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + handler.handleSubmissions(List.of(submission), user, false); + + verify(jobRepository).createJob(any(Job.class)); + } + + @Test + @DisplayName("creates topic entries for a new accepted submission") + void createsTopics() { + LeetcodeSubmission submission = acceptedSubmission(200, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("200")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + handler.handleSubmissions(List.of(submission), user, false); + + verify(questionTopicRepository).createQuestionTopic(argThat(qt -> "array".equals(qt.getTopicSlug()))); + } + + @Test + @DisplayName("returns the accepted submission with a title and question ID") + void returnsAcceptedSubmission() { + LeetcodeSubmission submission = acceptedSubmission(200, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("200")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + ArrayList result = handler.handleSubmissions(List.of(submission), user, false); + + assertEquals(1, result.size()); + assertEquals("two-sum", result.get(0).title()); + assertNotNull(result.get(0).questionId()); + } + + @Test + @DisplayName("updates the user's leaderboard score for a new accepted submission") + void updatesLeaderboardScore() { + LeetcodeSubmission submission = acceptedSubmission(200, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("200")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + handler.handleSubmissions(List.of(submission), user, false); + + verify(leaderboardRepository).updateUserPointsFromLeaderboard(eq(LEADERBOARD_ID), eq(USER_ID), anyInt()); + } + + @Test + @DisplayName("awards 0 points when the user already solved the same question") + void zeroPtsForDuplicateQuestion() { + LeetcodeSubmission sub = acceptedSubmission(300, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("300")).thenReturn(false); + + Question existingQ = + Question.builder().id("q-old").questionSlug("two-sum").build(); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(existingQ); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + ArrayList result = handler.handleSubmissions(List.of(sub), user, false); + + assertEquals(1, result.size()); + assertEquals(0, result.get(0).points()); + } + + @Test + @DisplayName("awards 0 points when submission timestamp is before leaderboard creation") + void zeroPtsForOldSubmission() { + LeetcodeSubmission oldSub = acceptedSubmission(400, "two-sum", LocalDateTime.of(2024, 12, 31, 23, 59)); + when(questionRepository.questionExistsBySubmissionId("400")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + ArrayList result = handler.handleSubmissions(List.of(oldSub), user, false); + + assertEquals(1, result.size()); + assertEquals(0, result.get(0).points()); + } + + @Test + @DisplayName("throws RuntimeException when no recent leaderboard exists") + void throwsWhenNoLeaderboard() { + when(leaderboardRepository.getRecentLeaderboardMetadata()).thenReturn(Optional.empty()); + + LeetcodeSubmission sub = acceptedSubmission(500, "two-sum", LocalDateTime.now()); + when(questionRepository.questionExistsBySubmissionId("500")).thenReturn(false); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + assertThrows(RuntimeException.class, () -> handler.handleSubmissions(List.of(sub), user, false)); + } + + @Test + @DisplayName("uses findQuestionBySlugFast when fast=true") + void usesSlugFastInFastMode() { + LeetcodeSubmission sub = acceptedSubmission(600, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("600")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlugFast("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + handler.handleSubmissions(List.of(sub), user, true); + + verify(leetcodeClient).findQuestionBySlugFast("two-sum"); + verify(leetcodeClient, never()).findQuestionBySlug(anyString()); + } + + @Test + @DisplayName("distinctByKey filters duplicates by the given key") + void distinctByKeyWorks() { + List input = List.of("aaa", "abc", "aab", "bbb"); + List result = input.stream() + .filter(SubmissionsHandler.distinctByKey(s -> s.charAt(0))) + .toList(); + + assertEquals(2, result.size()); + assertEquals("aaa", result.get(0)); + assertEquals("bbb", result.get(1)); + } + + @Test + @DisplayName("applies POTD multiplier when the submission matches the POTD slug") + void appliesPotdMultiplier() { + POTD potd = POTD.builder() + .slug("two-sum") + .multiplier(2.0f) + .createdAt(LocalDateTime.now()) + .build(); + when(potdRepository.getCurrentPOTD()).thenReturn(potd); + + LeetcodeSubmission sub = acceptedSubmission(700, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0)); + when(questionRepository.questionExistsBySubmissionId("700")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(leetcodeClient.findQuestionBySlug("two-sum")).thenReturn(leetcodeQuestion("two-sum", "Easy", 50f)); + + when(potdRepository.getCurrentPOTD()).thenReturn(null); + handler.handleSubmissions( + List.of(acceptedSubmission(701, "two-sum", LocalDateTime.of(2025, 6, 1, 12, 0))), user, false); + + reset(questionRepository); + when(questionRepository.questionExistsBySubmissionId("700")).thenReturn(false); + when(questionRepository.getQuestionBySlugAndUserId("two-sum", USER_ID)).thenReturn(null); + when(questionRepository.createQuestion(any(Question.class))).thenAnswer(inv -> { + Question q = inv.getArgument(0); + q.setId("q-potd"); + return q; + }); + when(potdRepository.getCurrentPOTD()).thenReturn(potd); + + ArrayList potdResult = handler.handleSubmissions(List.of(sub), user, false); + + assertFalse(potdResult.isEmpty()); + verify(questionRepository, atLeast(1)).createQuestion(any(Question.class)); + } +}