From 7f47f45944931a1882012e2e91e681139d549550 Mon Sep 17 00:00:00 2001 From: Arshadul Monir Date: Mon, 9 Feb 2026 20:11:22 -0500 Subject: [PATCH 1/4] 719: Added route to send test discord message to club 719: Admin message deletes self after 10 seconds --- .../codebloom/api/admin/AdminController.java | 25 +++++++++++++ .../common/components/DiscordClubManager.java | 35 +++++++++++++++++++ .../codebloom/jda/client/JDAClient.java | 19 +++++++--- 3 files changed, 74 insertions(+), 5 deletions(-) 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 629f6d605..768a9f1cd 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java @@ -257,4 +257,29 @@ public ResponseEntity>> getIncompleteQues return ResponseEntity.ok(ApiResponder.success( "Retrieved " + incompleteQuestionsDto.size() + " incomplete questions.", incompleteQuestionsDto)); } + + @Operation( + summary = "Send a message to a club's discord", + description = """ + Sends an embedded message to the leaderboard channel of the guild associated with their club, provided its clubId. Only accessible to admins. + """, + responses = { + @ApiResponse(responseCode = "200", description = "Message was sent successfully"), + @ApiResponse( + responseCode = "404", + description = "Something went wrong", + content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class))), + }) + @PostMapping("/discord/message/test") + public ResponseEntity> sendDiscordMessage( + @RequestBody final String clubId, final HttpServletRequest request) { + protector.validateAdminSession(request); + boolean sentMessage = discordClubManager.sendTestEmbedMessageToClub(clubId); + + if (!sentMessage) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponder.failure("Hmm, something went wrong.")); + } + return ResponseEntity.ok(ApiResponder.success("Message successfully sent!", Empty.of())); + } } 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 dac1c564c..10e26b226 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -269,4 +270,38 @@ public MessageCreateData buildLeaderboardMessageForClub(String guildId, boolean return MessageCreateData.fromEmbeds(embed); } + + public boolean sendTestEmbedMessageToClub(String clubId) { + try { + Optional clubOpt = discordClubRepository.getDiscordClubById(clubId); + if (clubOpt.isEmpty()) { + log.warn("No DiscordClub found for clubId={}", clubId); + return false; + } + DiscordClub club = clubOpt.get(); + String description = String.format(""" + This is a test message ensuring that the integration is working as expected. Please ignore. + """, club.getName()); + + var guildId = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getGuildId); + var channelId = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getLeaderboardChannelId); + + jdaClient.sendEmbedWithImages( + EmbeddedImagesMessageOptions.builder() + .guildId(Long.valueOf(guildId.get())) + .channelId(Long.valueOf(channelId.get())) + .description(description) + .title("Message for %s".formatted(club.getName())) + .footerText("Codebloom - LeetCode Leaderboard for %s".formatted(club.getName())) + .footerIcon("%s/favicon.ico".formatted(serverUrlUtils.getUrl())) + .color(new Color(69, 129, 103)) + .build(), + 10, + TimeUnit.SECONDS); + return true; + } catch (Exception e) { + log.error("Error in DiscordClubManager when sending test message", e); + return false; + } + } } diff --git a/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java b/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java index 7cca855cd..f74ebac1a 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; @@ -117,12 +118,13 @@ public void sendEmbedWithImage(final EmbeddedMessageOptions options) { log.info("Message has been queued"); } - public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { + public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options, long deletionTime, TimeUnit timeUnit) { Guild guild = getGuildById(options.getGuildId()); if (guild == null) { log.error("Guild does not exist."); return; } + TextChannel channel = guild.getTextChannelById(options.getChannelId()); if (channel == null) { log.error("Channel does not exist on the given guild."); @@ -153,7 +155,6 @@ public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { if (i == 0) { baseEmbed.setImage("attachment://" + fileNames.get(0)); } else { - EmbedBuilder additionalEmbed = new EmbedBuilder() .setUrl("https://codebloom.patinanetwork.org") .setImage("attachment://" + fileNames.get(i)); @@ -162,12 +163,20 @@ public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { } } - var messageCreationAction = channel.sendMessageEmbeds(embeds); + var action = channel.sendMessageEmbeds(embeds); if (!uploads.isEmpty()) { - messageCreationAction.setFiles(uploads); + action.setFiles(uploads); } - messageCreationAction.queue(); + action.queue(message -> { + if (deletionTime > 0) { + message.delete().queueAfter(deletionTime, timeUnit); + } + }); + } + + public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { + sendEmbedWithImages(options, 0, TimeUnit.SECONDS); } } From 240301c61965360c1e85c1db0ec60bf19575f254 Mon Sep 17 00:00:00 2001 From: Arshadul Monir Date: Tue, 10 Feb 2026 13:00:28 -0500 Subject: [PATCH 2/4] 719: Added backend tests 719: Added tests for sendDiscordMessage 719: Updated mockio test 719: Added tests for JDAClient 719: Added private to setup functions 719: Added delete test 719: Finished AdminController Tests 719: Updated DiscordClubManagerTest --- .../codebloom/jda/client/JDAClient.java | 21 +- .../api/admin/AdminControllerTest.java | 175 ++++++++++++- .../components/DiscordClubManagerTest.java | 47 ++++ .../codebloom/jda/JDAClientTest.java | 239 ++++++++++++++++++ 4 files changed, 466 insertions(+), 16 deletions(-) create mode 100644 src/test/java/org/patinanetwork/codebloom/jda/JDAClientTest.java diff --git a/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java b/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java index f74ebac1a..46e864e65 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java @@ -3,7 +3,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; @@ -44,7 +43,7 @@ public class JDAClient { @Getter private final JDALogReportingProperties jdaLogReportingProperties; - JDAClient( + public JDAClient( final JDAClientManager jdaClientManager, final JDAPatinaProperties jdaPatinaProperties, final JDAErrorReportingProperties jdaReportingProperties, @@ -118,13 +117,12 @@ public void sendEmbedWithImage(final EmbeddedMessageOptions options) { log.info("Message has been queued"); } - public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options, long deletionTime, TimeUnit timeUnit) { + public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { Guild guild = getGuildById(options.getGuildId()); if (guild == null) { log.error("Guild does not exist."); return; } - TextChannel channel = guild.getTextChannelById(options.getChannelId()); if (channel == null) { log.error("Channel does not exist on the given guild."); @@ -155,6 +153,7 @@ public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options, long if (i == 0) { baseEmbed.setImage("attachment://" + fileNames.get(0)); } else { + EmbedBuilder additionalEmbed = new EmbedBuilder() .setUrl("https://codebloom.patinanetwork.org") .setImage("attachment://" + fileNames.get(i)); @@ -163,20 +162,12 @@ public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options, long } } - var action = channel.sendMessageEmbeds(embeds); + var messageCreationAction = channel.sendMessageEmbeds(embeds); if (!uploads.isEmpty()) { - action.setFiles(uploads); + messageCreationAction.setFiles(uploads); } - action.queue(message -> { - if (deletionTime > 0) { - message.delete().queueAfter(deletionTime, timeUnit); - } - }); - } - - public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { - sendEmbedWithImages(options, 0, TimeUnit.SECONDS); + messageCreationAction.queue(); } } 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 931e416af..dc079f10c 100644 --- a/src/test/java/org/patinanetwork/codebloom/api/admin/AdminControllerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/api/admin/AdminControllerTest.java @@ -6,21 +6,32 @@ import jakarta.servlet.http.HttpServletRequest; import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.patinanetwork.codebloom.api.admin.body.DeleteAnnouncementBody; import org.patinanetwork.codebloom.api.admin.body.NewLeaderboardBody; +import org.patinanetwork.codebloom.api.admin.body.jda.DeleteMessageBody; import org.patinanetwork.codebloom.common.components.DiscordClubManager; import org.patinanetwork.codebloom.common.components.LeaderboardManager; +import org.patinanetwork.codebloom.common.db.models.announcement.Announcement; +import org.patinanetwork.codebloom.common.db.models.discord.DiscordClub; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; +import org.patinanetwork.codebloom.common.db.models.question.QuestionWithUser; import org.patinanetwork.codebloom.common.db.repos.announcement.AnnouncementRepository; +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.question.QuestionRepository; import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; import org.patinanetwork.codebloom.common.dto.ApiResponder; import org.patinanetwork.codebloom.common.dto.Empty; +import org.patinanetwork.codebloom.common.dto.question.QuestionWithUserDto; import org.patinanetwork.codebloom.common.security.Protector; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; public class AdminControllerTest { @@ -31,6 +42,7 @@ public class AdminControllerTest { private final Protector protector = mock(Protector.class); private final DiscordClubManager discordClubManager = mock(DiscordClubManager.class); private final LeaderboardManager leaderboardManager = mock(LeaderboardManager.class); + private final DiscordClubRepository discordClubRepository = mock(DiscordClubRepository.class); private final HttpServletRequest request = mock(HttpServletRequest.class); private final AdminController adminController; @@ -43,7 +55,8 @@ public AdminControllerTest() { announcementRepository, questionRepository, discordClubManager, - leaderboardManager)); + leaderboardManager, + discordClubRepository)); } @BeforeEach @@ -343,4 +356,164 @@ void testCreateLeaderboardWithNullOptionalFields() { && leaderboard.getSyntaxHighlightingLanguage() == null)); verify(leaderboardRepository).addAllUsersToLeaderboard(any()); } + + @Test + void testDeleteAnnouncementNull() { + DeleteAnnouncementBody body = DeleteAnnouncementBody.builder() + .id("4f6bbb9a-0baa-11f1-9607-77d42f1cf060") + .build(); + when(announcementRepository.getAnnouncementById(anyString())).thenReturn(null); + + ResponseStatusException exception = + assertThrows(ResponseStatusException.class, () -> adminController.deleteAnnouncement(body, request)); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); + assertEquals("Announcement does not exist", exception.getReason()); + } + + @Test + void testDeleteAnnouncementFailure() { + DeleteAnnouncementBody body = DeleteAnnouncementBody.builder() + .id("4f6bbb9a-0baa-11f1-9607-77d42f1cf060") + .build(); + Announcement mockAnnouncement = mock(Announcement.class); + when(announcementRepository.getAnnouncementById(anyString())).thenReturn(mockAnnouncement); + when(announcementRepository.updateAnnouncement(mockAnnouncement)).thenReturn(false); + + ResponseEntity> response = adminController.deleteAnnouncement(body, request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertEquals("Hmm, something went wrong.", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testDeleteAnnouncementSuccess() { + DeleteAnnouncementBody body = DeleteAnnouncementBody.builder() + .id("4f6bbb9a-0baa-11f1-9607-77d42f1cf060") + .build(); + Announcement mockAnnouncement = mock(Announcement.class); + when(announcementRepository.getAnnouncementById(anyString())).thenReturn(mockAnnouncement); + when(announcementRepository.updateAnnouncement(mockAnnouncement)).thenReturn(true); + + ResponseEntity> response = adminController.deleteAnnouncement(body, request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("Announcement successfully disabled!", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testGetIncompleteQuestionNoQuestions() { + when(questionRepository.getAllIncompleteQuestionsWithUser()).thenReturn(new ArrayList()); + + ResponseStatusException exception = + assertThrows(ResponseStatusException.class, () -> adminController.getIncompleteQuestions(request)); + assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode()); + assertEquals("No Incomplete Questions", exception.getReason()); + } + + @Test + void testGetIncompleteQuestionSuccess() { + QuestionWithUser qwu = QuestionWithUser.builder().build(); + + when(questionRepository.getAllIncompleteQuestionsWithUser()).thenReturn(new ArrayList<>(List.of(qwu))); + + ResponseEntity>> response = + adminController.getIncompleteQuestions(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals(1, response.getBody().getPayload().size()); + assertEquals("Retrieved 1 incomplete questions.", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testSendDiscordMessageInvalidClub() { + DiscordClub club = mock(DiscordClub.class); + when(discordClubManager.sendTestEmbedMessageToClub(club)).thenReturn(false); + + String clubId = "bbf4734a-06b6-11f1-869c-07599d6a11f7"; + ResponseEntity> response = adminController.sendDiscordMessage(clubId, request); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Club not found.", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testSendDiscordMessageFailure() { + String clubId = "bbf4734a-06b6-11f1-869c-07599d6a11f7"; + DiscordClub club = mock(DiscordClub.class); + + when(discordClubRepository.getDiscordClubById(clubId)).thenReturn(Optional.of(club)); + + when(discordClubManager.sendTestEmbedMessageToClub(club)).thenReturn(false); + + ResponseEntity> response = adminController.sendDiscordMessage(clubId, request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Hmm, something went wrong.", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testSendDiscordMessageSuccess() { + String clubId = "bbf4734a-06b6-11f1-869c-07599d6a11f7"; + DiscordClub club = mock(DiscordClub.class); + + when(discordClubRepository.getDiscordClubById(clubId)).thenReturn(Optional.of(club)); + + when(discordClubManager.sendTestEmbedMessageToClub(club)).thenReturn(true); + + ResponseEntity> response = adminController.sendDiscordMessage(clubId, request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals("Message successfully sent!", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testDeleteDiscordMessageFailure() { + when(discordClubManager.deleteMessageById(anyLong(), anyLong())).thenReturn(false); + DeleteMessageBody body = + DeleteMessageBody.builder().channelId(999L).messageId(123L).build(); + + ResponseEntity> response = adminController.deleteDiscordMessage(body, request); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertEquals("Hmm, something went wrong.", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } + + @Test + void testDeleteDiscordMessageSuccess() { + when(discordClubManager.deleteMessageById(anyLong(), anyLong())).thenReturn(true); + DeleteMessageBody body = + DeleteMessageBody.builder().channelId(999L).messageId(123L).build(); + + ResponseEntity> response = adminController.deleteDiscordMessage(body, request); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("Discord Message successfully deleted", response.getBody().getMessage()); + + verify(protector).validateAdminSession(request); + } } 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 c7976ae40..d639a5d90 100644 --- a/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/common/components/DiscordClubManagerTest.java @@ -324,6 +324,53 @@ void testBuildWeeklyLeaderboardMessageForClub() { assertTrue(description.contains("Here is a weekly update")); } + @Test + void testSendTestEmbedMessageToClubNoGuildOrClub() { + DiscordClub mockClub = mock(DiscordClub.class); + + when(mockClub.getId()).thenReturn("club-id-1"); + when(mockClub.getDiscordClubMetadata()).thenReturn(Optional.empty()); + + boolean result = discordClubManager.sendTestEmbedMessageToClub(mockClub); + + assertFalse(result); + verify(jdaClient, never()).sendEmbedWithImages(any()); + + assertTrue(logWatcher.list.stream() + .anyMatch(e -> e.getFormattedMessage().contains("Missing guildId or leaderboardChannelId"))); + } + + @Test + void testSendTestEmbedMessageToClubFailure() { + DiscordClub mockClub = createMockDiscordClub("Test Club", Tag.Rpi); + + doThrow(new RuntimeException()).when(jdaClient).sendEmbedWithImages(any(EmbeddedImagesMessageOptions.class)); + + boolean result = discordClubManager.sendTestEmbedMessageToClub(mockClub); + + assertFalse(result); + + verify(jdaClient).sendEmbedWithImages(any(EmbeddedImagesMessageOptions.class)); + + assertTrue(logWatcher.list.stream().anyMatch(e -> e.getFormattedMessage() + .contains("Error in DiscordClubManager when sending test message"))); + } + + @Test + void testSendTestEmbedMessageToClubSuccess() { + DiscordClub mockClub = createMockDiscordClub("Test Club", Tag.Rpi); + + boolean result = discordClubManager.sendTestEmbedMessageToClub(mockClub); + assertTrue(result); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(EmbeddedImagesMessageOptions.class); + verify(jdaClient).sendEmbedWithImages(captor.capture()); + + String description = captor.getValue().getDescription(); + assertTrue(description.contains("test message")); + } + private DiscordClub createMockDiscordClub(final String name, final Tag tag) { DiscordClubMetadata metadata = mock(DiscordClubMetadata.class); when(metadata.getGuildId()).thenReturn(Optional.of("123456789")); diff --git a/src/test/java/org/patinanetwork/codebloom/jda/JDAClientTest.java b/src/test/java/org/patinanetwork/codebloom/jda/JDAClientTest.java new file mode 100644 index 000000000..8772c6598 --- /dev/null +++ b/src/test/java/org/patinanetwork/codebloom/jda/JDAClientTest.java @@ -0,0 +1,239 @@ +package org.patinanetwork.codebloom.jda; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.util.List; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import net.dv8tion.jda.api.utils.FileUpload; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.codebloom.jda.client.JDAClient; +import org.patinanetwork.codebloom.jda.client.options.EmbeddedImagesMessageOptions; +import org.patinanetwork.codebloom.jda.client.options.EmbeddedMessageOptions; +import org.patinanetwork.codebloom.jda.properties.patina.JDAPatinaProperties; +import org.patinanetwork.codebloom.jda.properties.reporting.JDAErrorReportingProperties; +import org.patinanetwork.codebloom.jda.properties.reporting.JDALogReportingProperties; + +public class JDAClientTest { + private JDA jda = mock(JDA.class); + private JDAPatinaProperties jdaPatinaProperties = mock(JDAPatinaProperties.class); + private JDAErrorReportingProperties jdaErrorReportingProperties = mock(JDAErrorReportingProperties.class); + private JDALogReportingProperties jdaLogReportingProperties = mock(JDALogReportingProperties.class); + private JDAClientManager jdaClientManager = mock(JDAClientManager.class); + private EmbeddedMessageOptions options = mock(EmbeddedMessageOptions.class); + private EmbeddedImagesMessageOptions imagesOptions = mock(EmbeddedImagesMessageOptions.class); + + private JDAClient client; + + @BeforeEach + void setUp() { + when(jdaClientManager.getClient()).thenReturn(jda); + client = new JDAClient( + jdaClientManager, jdaPatinaProperties, jdaErrorReportingProperties, jdaLogReportingProperties); + } + + @Test + void testGetGuildById() { + Guild guild1 = mock(Guild.class); + Guild guild2 = mock(Guild.class); + + when(client.getGuilds()).thenReturn(List.of(guild1, guild2)); + when(guild1.getId()).thenReturn("123456789"); + + Guild found = client.getGuildById(123456789); + + assertEquals(found, guild1); + } + + @Test + void testGetMemberListByGuildIdHasMember() { + Guild guild = mock(Guild.class); + Member m1 = mock(Member.class); + Member m2 = mock(Member.class); + + when(guild.getId()).thenReturn("123"); + when(guild.getMembers()).thenReturn(List.of(m1, m2)); + when(jda.getGuilds()).thenReturn(List.of(guild)); + + List result = client.getMemberListByGuildId("123"); + + assertEquals(2, result.size()); + assertSame(m1, result.get(0)); + assertSame(m2, result.get(1)); + + verify(jda).getGuilds(); + verify(guild).getMembers(); + } + + @Test + void testGetMemberListByGuildIdHasMemberEmpty() { + Guild guild = mock(Guild.class); + + when(guild.getId()).thenReturn("123"); + when(guild.getMembers()).thenReturn(List.of()); + when(jda.getGuilds()).thenReturn(List.of(guild)); + + List result = client.getMemberListByGuildId("123"); + + assertEquals(0, result.size()); + + verify(jda).getGuilds(); + verify(guild).getMembers(); + } + + @Test + void testSendEmbedWithImageNoGuild() { + setupOptions(); + when(jda.getGuilds()).thenReturn(List.of()); + + client.sendEmbedWithImage(options); + + verify(jda).getGuilds(); + verifyNoMoreInteractions(jda); + } + + @Test + void testSendEmbedWithImageNoChannel() { + setupOptions(); + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("123456789"); + when(jda.getGuilds()).thenReturn(List.of(guild)); + + when(guild.getTextChannelById(anyLong())).thenReturn(null); + + client.sendEmbedWithImage(options); + + verify(guild).getTextChannelById(987654321L); + } + + @Test + void testSendEmbedWithImageSuccess() { + setupOptions(); + + Guild guild = mock(Guild.class); + TextChannel channel = mock(TextChannel.class); + + when(guild.getId()).thenReturn("123456789"); + when(jda.getGuilds()).thenReturn(List.of(guild)); + when(guild.getTextChannelById(987654321L)).thenReturn(channel); + + MessageCreateAction action = mock(MessageCreateAction.class); + when(channel.sendFiles(any(FileUpload.class))).thenReturn(action); + when(action.setEmbeds(any(MessageEmbed.class))).thenReturn(action); + + client.sendEmbedWithImage(options); + + verify(channel).sendFiles(any(FileUpload.class)); + verify(action).setEmbeds(any(MessageEmbed.class)); + verify(action).queue(); + } + + @Test + void testSendEmbedWithImagesNoGuild() { + setupImagesOptions(); + when(jda.getGuilds()).thenReturn(List.of()); + + client.sendEmbedWithImages(imagesOptions); + + verify(jda).getGuilds(); + verifyNoMoreInteractions(jda); + } + + @Test + void testSendEmbedWithImagesNoChannel() { + setupImagesOptions(); + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("123456789"); + when(jda.getGuilds()).thenReturn(List.of(guild)); + + when(guild.getTextChannelById(anyLong())).thenReturn(null); + + client.sendEmbedWithImages(imagesOptions); + + verify(guild).getTextChannelById(987654321L); + } + + @Test + void testSendEmbedWithImagesSuccess() { + setupImagesOptions(); + + Guild guild = mock(Guild.class); + TextChannel channel = mock(TextChannel.class); + + when(guild.getId()).thenReturn("123456789"); + when(jda.getGuilds()).thenReturn(List.of(guild)); + when(guild.getTextChannelById(987654321L)).thenReturn(channel); + + MessageCreateAction action = mock(MessageCreateAction.class); + when(channel.sendMessageEmbeds(anyList())).thenReturn(action); + when(action.setFiles(anyList())).thenReturn(action); + + client.sendEmbedWithImages(imagesOptions); + + ArgumentCaptor> embedsCaptor = ArgumentCaptor.forClass(List.class); + verify(channel).sendMessageEmbeds(embedsCaptor.capture()); + List embeds = embedsCaptor.getValue(); + + assertNotNull(embeds); + + verify(action).setFiles(anyList()); + } + + @Test + void testDeleteMessageId() { + TextChannel mockChannel = mock(TextChannel.class); + AuditableRestAction mockDeleteAction = mock(AuditableRestAction.class); + + when(jda.getTextChannelById(anyLong())).thenReturn(mockChannel); + when(mockChannel.deleteMessageById(anyLong())).thenReturn(mockDeleteAction); + when(mockDeleteAction.complete()).thenReturn(null); + + boolean result = client.deleteMessageById(987654321L, 1223334444L); + assertTrue(result); + verify(mockChannel).deleteMessageById(1223334444L); + } + + private void setupOptions() { + when(options.getGuildId()).thenReturn(123456789L); + when(options.getChannelId()).thenReturn(987654321L); + when(options.getTitle()).thenReturn("Title"); + when(options.getDescription()).thenReturn("Desc"); + when(options.getFooterText()).thenReturn("Footer"); + when(options.getFooterIcon()).thenReturn("https://example.com/icon.png"); + when(options.getColor()).thenReturn(Color.BLACK); + + when(options.getFileName()).thenReturn("img.png"); + when(options.getFileBytes()).thenReturn(new byte[] {1, 2, 3}); + } + + private void setupImagesOptions() { + when(imagesOptions.getGuildId()).thenReturn(123456789L); + when(imagesOptions.getChannelId()).thenReturn(987654321L); + when(imagesOptions.getTitle()).thenReturn("Title"); + when(imagesOptions.getDescription()).thenReturn("Desc"); + when(imagesOptions.getFooterText()).thenReturn("Footer"); + when(imagesOptions.getFooterIcon()).thenReturn("https://example.com/icon.png"); + when(imagesOptions.getColor()).thenReturn(Color.BLACK); + + when(imagesOptions.getFilesBytes()).thenReturn(List.of(new byte[] {1}, new byte[] {2})); + when(imagesOptions.getFileNames()).thenReturn(List.of("a.png", "b.png")); + } +} From 26136e94f360eb6ec8291c49ea98137c2a323328 Mon Sep 17 00:00:00 2001 From: Arshadul Monir Date: Tue, 10 Feb 2026 15:25:30 -0500 Subject: [PATCH 3/4] 719: Output better messages for invalid or incorrect clubId 719: Undo auto deletion in DiscordClubManager 719: Removed auto deletion logic from test --- .../codebloom/api/admin/AdminController.java | 17 +++++++++-- .../common/components/DiscordClubManager.java | 30 +++++++------------ 2 files changed, 25 insertions(+), 22 deletions(-) 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 768a9f1cd..d19440b57 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java @@ -11,6 +11,7 @@ import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.patinanetwork.codebloom.api.admin.body.CreateAnnouncementBody; import org.patinanetwork.codebloom.api.admin.body.DeleteAnnouncementBody; import org.patinanetwork.codebloom.api.admin.body.NewLeaderboardBody; @@ -18,10 +19,12 @@ import org.patinanetwork.codebloom.common.components.DiscordClubManager; import org.patinanetwork.codebloom.common.components.LeaderboardManager; import org.patinanetwork.codebloom.common.db.models.announcement.Announcement; +import org.patinanetwork.codebloom.common.db.models.discord.DiscordClub; import org.patinanetwork.codebloom.common.db.models.leaderboard.Leaderboard; import org.patinanetwork.codebloom.common.db.models.question.QuestionWithUser; import org.patinanetwork.codebloom.common.db.models.user.User; import org.patinanetwork.codebloom.common.db.repos.announcement.AnnouncementRepository; +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.question.QuestionRepository; import org.patinanetwork.codebloom.common.db.repos.user.UserRepository; @@ -54,6 +57,7 @@ public class AdminController { private final Protector protector; private final DiscordClubManager discordClubManager; private final LeaderboardManager leaderboardManager; + private final DiscordClubRepository discordClubRepository; public AdminController( final LeaderboardRepository leaderboardRepository, @@ -62,7 +66,8 @@ public AdminController( final AnnouncementRepository announcementRepository, final QuestionRepository questionRepository, final DiscordClubManager discordClubManager, - final LeaderboardManager leaderboardManager) { + final LeaderboardManager leaderboardManager, + final DiscordClubRepository discordClubRepository) { this.leaderboardRepository = leaderboardRepository; this.protector = protector; this.userRepository = userRepository; @@ -70,6 +75,7 @@ public AdminController( this.questionRepository = questionRepository; this.discordClubManager = discordClubManager; this.leaderboardManager = leaderboardManager; + this.discordClubRepository = discordClubRepository; } @Operation(summary = "Drops current leaderboard and add new one", description = """ @@ -274,7 +280,14 @@ public ResponseEntity>> getIncompleteQues public ResponseEntity> sendDiscordMessage( @RequestBody final String clubId, final HttpServletRequest request) { protector.validateAdminSession(request); - boolean sentMessage = discordClubManager.sendTestEmbedMessageToClub(clubId); + + Optional clubOpt = discordClubRepository.getDiscordClubById(clubId); + if (clubOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure("Club not found.")); + } + DiscordClub club = clubOpt.get(); + + boolean sentMessage = discordClubManager.sendTestEmbedMessageToClub(club); if (!sentMessage) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) 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 10e26b226..f1f05b4d1 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java @@ -5,7 +5,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -271,14 +270,8 @@ public MessageCreateData buildLeaderboardMessageForClub(String guildId, boolean return MessageCreateData.fromEmbeds(embed); } - public boolean sendTestEmbedMessageToClub(String clubId) { + public boolean sendTestEmbedMessageToClub(DiscordClub club) { try { - Optional clubOpt = discordClubRepository.getDiscordClubById(clubId); - if (clubOpt.isEmpty()) { - log.warn("No DiscordClub found for clubId={}", clubId); - return false; - } - DiscordClub club = clubOpt.get(); String description = String.format(""" This is a test message ensuring that the integration is working as expected. Please ignore. """, club.getName()); @@ -286,18 +279,15 @@ public boolean sendTestEmbedMessageToClub(String clubId) { var guildId = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getGuildId); var channelId = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getLeaderboardChannelId); - jdaClient.sendEmbedWithImages( - EmbeddedImagesMessageOptions.builder() - .guildId(Long.valueOf(guildId.get())) - .channelId(Long.valueOf(channelId.get())) - .description(description) - .title("Message for %s".formatted(club.getName())) - .footerText("Codebloom - LeetCode Leaderboard for %s".formatted(club.getName())) - .footerIcon("%s/favicon.ico".formatted(serverUrlUtils.getUrl())) - .color(new Color(69, 129, 103)) - .build(), - 10, - TimeUnit.SECONDS); + jdaClient.sendEmbedWithImages(EmbeddedImagesMessageOptions.builder() + .guildId(Long.valueOf(guildId.get())) + .channelId(Long.valueOf(channelId.get())) + .description(description) + .title("Message for %s".formatted(club.getName())) + .footerText("Codebloom - LeetCode Leaderboard for %s".formatted(club.getName())) + .footerIcon("%s/favicon.ico".formatted(serverUrlUtils.getUrl())) + .color(new Color(69, 129, 103)) + .build()); return true; } catch (Exception e) { log.error("Error in DiscordClubManager when sending test message", e); From 5c3da31a09d4b1b149ecda12f5471f8f11ad802f Mon Sep 17 00:00:00 2001 From: Arshadul Monir Date: Sun, 15 Feb 2026 22:47:41 -0500 Subject: [PATCH 4/4] 719: Endpoint to delete discord message --- .../codebloom/api/admin/AdminController.java | 18 +++++++++++++++ .../api/admin/body/jda/DeleteMessageBody.java | 20 +++++++++++++++++ .../common/components/DiscordClubManager.java | 22 ++++++++++++------- .../codebloom/jda/client/JDAClient.java | 17 ++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/patinanetwork/codebloom/api/admin/body/jda/DeleteMessageBody.java 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 d19440b57..64ce7ff03 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/admin/AdminController.java @@ -16,6 +16,7 @@ import org.patinanetwork.codebloom.api.admin.body.DeleteAnnouncementBody; import org.patinanetwork.codebloom.api.admin.body.NewLeaderboardBody; import org.patinanetwork.codebloom.api.admin.body.UpdateAdminBody; +import org.patinanetwork.codebloom.api.admin.body.jda.DeleteMessageBody; import org.patinanetwork.codebloom.common.components.DiscordClubManager; import org.patinanetwork.codebloom.common.components.LeaderboardManager; import org.patinanetwork.codebloom.common.db.models.announcement.Announcement; @@ -37,6 +38,7 @@ import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -295,4 +297,20 @@ public ResponseEntity> sendDiscordMessage( } return ResponseEntity.ok(ApiResponder.success("Message successfully sent!", Empty.of())); } + + @DeleteMapping("/discord/message") + public ResponseEntity> deleteDiscordMessage( + @Valid @RequestBody final DeleteMessageBody deleteMessageBody, final HttpServletRequest request) { + protector.validateAdminSession(request); + + boolean isDeleted = discordClubManager.deleteMessageById( + deleteMessageBody.getChannelId(), deleteMessageBody.getMessageId()); + + if (!isDeleted) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponder.failure("Hmm, something went wrong.")); + } + + return ResponseEntity.ok(ApiResponder.success("Discord Message successfully deleted", Empty.of())); + } } diff --git a/src/main/java/org/patinanetwork/codebloom/api/admin/body/jda/DeleteMessageBody.java b/src/main/java/org/patinanetwork/codebloom/api/admin/body/jda/DeleteMessageBody.java new file mode 100644 index 000000000..0cfae5d6e --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/api/admin/body/jda/DeleteMessageBody.java @@ -0,0 +1,20 @@ +package org.patinanetwork.codebloom.api.admin.body.jda; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +@AllArgsConstructor +public class DeleteMessageBody { + + @NotNull + private Long channelId; + + @NotNull + private Long messageId; +} 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 f1f05b4d1..50fb91215 100644 --- a/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java +++ b/src/main/java/org/patinanetwork/codebloom/common/components/DiscordClubManager.java @@ -272,16 +272,18 @@ public MessageCreateData buildLeaderboardMessageForClub(String guildId, boolean public boolean sendTestEmbedMessageToClub(DiscordClub club) { try { - String description = String.format(""" + String description = """ This is a test message ensuring that the integration is working as expected. Please ignore. - """, club.getName()); - - var guildId = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getGuildId); - var channelId = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getLeaderboardChannelId); - + """; + var guildIdOpt = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getGuildId); + var channelIdOpt = club.getDiscordClubMetadata().flatMap(DiscordClubMetadata::getLeaderboardChannelId); + if (guildIdOpt.isEmpty() || channelIdOpt.isEmpty()) { + log.warn("Missing guildId or leaderboardChannelId for club {}", club.getId()); + return false; + } jdaClient.sendEmbedWithImages(EmbeddedImagesMessageOptions.builder() - .guildId(Long.valueOf(guildId.get())) - .channelId(Long.valueOf(channelId.get())) + .guildId(Long.valueOf(guildIdOpt.get())) + .channelId(Long.valueOf(channelIdOpt.get())) .description(description) .title("Message for %s".formatted(club.getName())) .footerText("Codebloom - LeetCode Leaderboard for %s".formatted(club.getName())) @@ -294,4 +296,8 @@ public boolean sendTestEmbedMessageToClub(DiscordClub club) { return false; } } + + public boolean deleteMessageById(Long channelId, Long messageId) { + return jdaClient.deleteMessageById(channelId, messageId); + } } diff --git a/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java b/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java index 46e864e65..059ba9b28 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/client/JDAClient.java @@ -11,6 +11,7 @@ import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; import net.dv8tion.jda.api.utils.FileUpload; import org.patinanetwork.codebloom.jda.JDAClientManager; import org.patinanetwork.codebloom.jda.client.options.EmbeddedImagesMessageOptions; @@ -170,4 +171,20 @@ public void sendEmbedWithImages(final EmbeddedImagesMessageOptions options) { messageCreationAction.queue(); } + + public boolean deleteMessageById(long channelId, long messageId) { + TextChannel channel = jda.getTextChannelById(channelId); + + if (channel == null) { + return false; + } + + try { + channel.deleteMessageById(messageId).complete(); + return true; + } catch (ErrorResponseException e) { + log.error("Failed to delete message.", e); + return false; + } + } }