From d06aed3222f4e6eeddc311adceab851620d89e2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:40:52 +0000 Subject: [PATCH 1/5] Initial plan From 9a609565a61f74e86f9ae59b3811611444883473 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:46:08 +0000 Subject: [PATCH 2/5] Add Discord webhook notifications for player join/quit events - Add DiscordWebhookService for sending messages to Discord webhooks - Add config options: discordWebhookEnabled, discordWebhookUrl, discordWebhookStaffOnly, discordWebhookJoinMessage, discordWebhookQuitMessage - Update JoinHandler and QuitHandler to send async Discord notifications - Add at.staff permission for staff-only webhook filtering - Add unit tests for DiscordWebhookService Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../activitytracker/ActivityTracker.java | 6 +- .../eventhandlers/JoinHandler.java | 27 +++- .../eventhandlers/QuitHandler.java | 27 +++- .../services/ConfigService.java | 36 ++++- .../services/DiscordWebhookService.java | 144 ++++++++++++++++++ src/main/resources/plugin.yml | 2 + .../services/DiscordWebhookServiceTest.java | 138 +++++++++++++++++ 7 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java create mode 100644 src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java diff --git a/src/main/java/dansplugins/activitytracker/ActivityTracker.java b/src/main/java/dansplugins/activitytracker/ActivityTracker.java index 91541a9..b5518bc 100644 --- a/src/main/java/dansplugins/activitytracker/ActivityTracker.java +++ b/src/main/java/dansplugins/activitytracker/ActivityTracker.java @@ -9,6 +9,7 @@ import dansplugins.activitytracker.factories.SessionFactory; import dansplugins.activitytracker.services.ActivityRecordService; import dansplugins.activitytracker.services.ConfigService; +import dansplugins.activitytracker.services.DiscordWebhookService; import dansplugins.activitytracker.services.StorageService; import dansplugins.activitytracker.api.RestApiService; import dansplugins.activitytracker.utils.Logger; @@ -48,6 +49,7 @@ public final class ActivityTracker extends PonderBukkitPlugin { private final SessionFactory sessionFactory = new SessionFactory(logger, persistentData); private final ActivityRecordFactory activityRecordFactory = new ActivityRecordFactory(logger, sessionFactory); private final ActivityRecordService activityRecordService = new ActivityRecordService(persistentData, activityRecordFactory, logger); + private final DiscordWebhookService discordWebhookService = new DiscordWebhookService(configService, logger); private RestApiService restApiService; /** @@ -146,8 +148,8 @@ private void performCompatibilityChecks() { private void registerEventHandlers() { EventHandlerRegistry eventHandlerRegistry = new EventHandlerRegistry(); ArrayList listeners = new ArrayList<>(Arrays.asList( - new JoinHandler(activityRecordService, persistentData, sessionFactory), - new QuitHandler(persistentData, logger) + new JoinHandler(activityRecordService, persistentData, sessionFactory, discordWebhookService, this), + new QuitHandler(persistentData, logger, discordWebhookService, this) )); eventHandlerRegistry.registerEventHandlers(listeners, this); } diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java index 10defd8..387e5a2 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java @@ -2,10 +2,12 @@ import dansplugins.activitytracker.data.PersistentData; import dansplugins.activitytracker.factories.SessionFactory; +import dansplugins.activitytracker.services.DiscordWebhookService; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; import dansplugins.activitytracker.objects.ActivityRecord; import dansplugins.activitytracker.objects.Session; @@ -18,11 +20,17 @@ public class JoinHandler implements Listener { private final ActivityRecordService activityRecordService; private final PersistentData persistentData; private final SessionFactory sessionFactory; + private final DiscordWebhookService discordWebhookService; + private final JavaPlugin plugin; - public JoinHandler(ActivityRecordService activityRecordService, PersistentData persistentData, SessionFactory sessionFactory) { + private static final String STAFF_PERMISSION = "at.staff"; + + public JoinHandler(ActivityRecordService activityRecordService, PersistentData persistentData, SessionFactory sessionFactory, DiscordWebhookService discordWebhookService, JavaPlugin plugin) { this.activityRecordService = activityRecordService; this.persistentData = persistentData; this.sessionFactory = sessionFactory; + this.discordWebhookService = discordWebhookService; + this.plugin = plugin; } @EventHandler() @@ -41,5 +49,22 @@ public void handle(PlayerJoinEvent event) { record.addSession(newSession); record.setMostRecentSession(newSession); } + + sendDiscordJoinNotification(player); + } + + private void sendDiscordJoinNotification(Player player) { + if (!discordWebhookService.isEnabled()) { + return; + } + if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { + return; + } + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { + @Override + public void run() { + discordWebhookService.sendJoinNotification(player.getName()); + } + }); } } \ No newline at end of file diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java index 9e58177..49112e0 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java @@ -1,11 +1,13 @@ package dansplugins.activitytracker.eventhandlers; import dansplugins.activitytracker.exceptions.NoSessionException; +import dansplugins.activitytracker.services.DiscordWebhookService; import dansplugins.activitytracker.utils.Logger; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.java.JavaPlugin; import dansplugins.activitytracker.data.PersistentData; import dansplugins.activitytracker.objects.ActivityRecord; @@ -17,10 +19,16 @@ public class QuitHandler implements Listener { private final PersistentData persistentData; private final Logger logger; + private final DiscordWebhookService discordWebhookService; + private final JavaPlugin plugin; - public QuitHandler(PersistentData persistentData, Logger logger) { + private static final String STAFF_PERMISSION = "at.staff"; + + public QuitHandler(PersistentData persistentData, Logger logger, DiscordWebhookService discordWebhookService, JavaPlugin plugin) { this.persistentData = persistentData; this.logger = logger; + this.discordWebhookService = discordWebhookService; + this.plugin = plugin; } @EventHandler() @@ -56,5 +64,22 @@ public void handle(PlayerQuitEvent event) { } catch (Exception e) { logger.log("ERROR: Failed to properly end session for " + player.getName() + ": " + e.getMessage()); } + + sendDiscordQuitNotification(player); + } + + private void sendDiscordQuitNotification(Player player) { + if (!discordWebhookService.isEnabled()) { + return; + } + if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { + return; + } + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { + @Override + public void run() { + discordWebhookService.sendQuitNotification(player.getName()); + } + }); } } \ No newline at end of file diff --git a/src/main/java/dansplugins/activitytracker/services/ConfigService.java b/src/main/java/dansplugins/activitytracker/services/ConfigService.java index a697742..367511c 100644 --- a/src/main/java/dansplugins/activitytracker/services/ConfigService.java +++ b/src/main/java/dansplugins/activitytracker/services/ConfigService.java @@ -44,6 +44,21 @@ public void saveMissingConfigDefaultsIfNotPresent() { if (!getConfig().isSet("restApiPort")) { getConfig().set("restApiPort", 8080); } + if (!getConfig().isSet("discordWebhookEnabled")) { + getConfig().set("discordWebhookEnabled", false); + } + if (!getConfig().isSet("discordWebhookUrl")) { + getConfig().set("discordWebhookUrl", ""); + } + if (!getConfig().isSet("discordWebhookStaffOnly")) { + getConfig().set("discordWebhookStaffOnly", false); + } + if (!getConfig().isSet("discordWebhookJoinMessage")) { + getConfig().set("discordWebhookJoinMessage", "\u2694\uFE0F **{player}** has joined the server!"); + } + if (!getConfig().isSet("discordWebhookQuitMessage")) { + getConfig().set("discordWebhookQuitMessage", "\uD83D\uDC4B **{player}** has left the server."); + } getConfig().options().copyDefaults(true); activityTracker.saveConfig(); } @@ -58,7 +73,8 @@ public void setConfigOption(String option, String value, CommandSender sender) { } else if (option.equalsIgnoreCase("restApiPort")) { getConfig().set(option, Integer.parseInt(value)); sender.sendMessage(ChatColor.GREEN + "Integer set."); - } else if (option.equalsIgnoreCase("debugMode") || option.equalsIgnoreCase("restApiEnabled")) { + } else if (option.equalsIgnoreCase("debugMode") || option.equalsIgnoreCase("restApiEnabled") + || option.equalsIgnoreCase("discordWebhookEnabled") || option.equalsIgnoreCase("discordWebhookStaffOnly")) { getConfig().set(option, Boolean.parseBoolean(value)); sender.sendMessage(ChatColor.GREEN + "Boolean set."); } else if (option.equalsIgnoreCase("")) { // no doubles yet @@ -81,14 +97,24 @@ public void sendConfigList(CommandSender sender) { sender.sendMessage(""); sender.sendMessage(ChatColor.GOLD + "┌─ " + ChatColor.YELLOW + "" + ChatColor.BOLD + "Activity Tracker" + ChatColor.RESET + ChatColor.GOLD + " ─ Config"); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "version: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "version: " + ChatColor.WHITE + getConfig().getString("version")); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "debugMode: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "debugMode: " + ChatColor.WHITE + getString("debugMode")); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiEnabled: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiEnabled: " + ChatColor.WHITE + getString("restApiEnabled")); - sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiPort: " + + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "restApiPort: " + ChatColor.WHITE + getString("restApiPort")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookEnabled: " + + ChatColor.WHITE + getString("discordWebhookEnabled")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookUrl: " + + ChatColor.WHITE + getString("discordWebhookUrl")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookStaffOnly: " + + ChatColor.WHITE + getString("discordWebhookStaffOnly")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookJoinMessage:" + + ChatColor.WHITE + " " + getString("discordWebhookJoinMessage")); + sender.sendMessage(ChatColor.GOLD + "│ " + ChatColor.GRAY + "discordWebhookQuitMessage:" + + ChatColor.WHITE + " " + getString("discordWebhookQuitMessage")); sender.sendMessage(ChatColor.GOLD + "└─────────────────────────"); } diff --git a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java new file mode 100644 index 0000000..f4154c7 --- /dev/null +++ b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java @@ -0,0 +1,144 @@ +package dansplugins.activitytracker.services; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import dansplugins.activitytracker.utils.Logger; + +/** + * Service for sending Discord webhook notifications when players join or leave the server. + * @author Daniel McCoy Stephenson + */ +public class DiscordWebhookService { + private final ConfigService configService; + private final Logger logger; + + public DiscordWebhookService(ConfigService configService, Logger logger) { + this.configService = configService; + this.logger = logger; + } + + /** + * Checks if the Discord webhook feature is enabled and configured. + * @return true if enabled and a webhook URL is set. + */ + public boolean isEnabled() { + if (!configService.getBoolean("discordWebhookEnabled")) { + return false; + } + String url = configService.getString("discordWebhookUrl"); + return url != null && !url.isEmpty(); + } + + /** + * Checks if webhooks should only fire for staff members. + * @return true if staff-only mode is active. + */ + public boolean isStaffOnly() { + return configService.getBoolean("discordWebhookStaffOnly"); + } + + /** + * Sends a player join notification to the configured Discord webhook. + * @param playerName The name of the player who joined. + */ + public void sendJoinNotification(String playerName) { + String template = configService.getString("discordWebhookJoinMessage"); + if (template == null || template.isEmpty()) { + return; + } + String message = template.replace("{player}", playerName); + sendWebhookMessage(message); + } + + /** + * Sends a player quit notification to the configured Discord webhook. + * @param playerName The name of the player who quit. + */ + public void sendQuitNotification(String playerName) { + String template = configService.getString("discordWebhookQuitMessage"); + if (template == null || template.isEmpty()) { + return; + } + String message = template.replace("{player}", playerName); + sendWebhookMessage(message); + } + + /** + * Sends a message to the configured Discord webhook URL. + * @param content The message content to send. + */ + private void sendWebhookMessage(String content) { + String webhookUrl = configService.getString("discordWebhookUrl"); + if (webhookUrl == null || webhookUrl.isEmpty()) { + return; + } + + try { + URL url = new URL(webhookUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(10000); + connection.setDoOutput(true); + + String jsonPayload = "{\"content\": \"" + escapeJson(content) + "\"}"; + + OutputStream os = connection.getOutputStream(); + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + os.close(); + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + logger.log("Discord webhook returned error code: " + responseCode); + } + } catch (IOException e) { + logger.log("Failed to send Discord webhook message: " + e.getMessage()); + } + } + + /** + * Escapes special characters for JSON string values. + * @param text The text to escape. + * @return The escaped text safe for JSON inclusion. + */ + private String escapeJson(String text) { + if (text == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + break; + } + } + return sb.toString(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b7099e7..7d51850 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -21,4 +21,6 @@ permissions: at.config: default: op at.list: + default: op + at.staff: default: op \ No newline at end of file diff --git a/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java new file mode 100644 index 0000000..143aa7e --- /dev/null +++ b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java @@ -0,0 +1,138 @@ +package dansplugins.activitytracker.services; + +import dansplugins.activitytracker.utils.Logger; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for DiscordWebhookService + */ +public class DiscordWebhookServiceTest { + + @Mock + private ConfigService configService; + + @Mock + private Logger logger; + + private DiscordWebhookService discordWebhookService; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + discordWebhookService = new DiscordWebhookService(configService, logger); + } + + @Test + public void testIsEnabledReturnsFalseWhenDisabled() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsFalseWhenUrlIsEmpty() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn(""); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsFalseWhenUrlIsNull() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn(null); + + assertFalse(discordWebhookService.isEnabled()); + } + + @Test + public void testIsEnabledReturnsTrueWhenConfigured() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); + + assertTrue(discordWebhookService.isEnabled()); + } + + @Test + public void testIsStaffOnlyReturnsConfigValue() { + when(configService.getBoolean("discordWebhookStaffOnly")).thenReturn(true); + assertTrue(discordWebhookService.isStaffOnly()); + + when(configService.getBoolean("discordWebhookStaffOnly")).thenReturn(false); + assertFalse(discordWebhookService.isStaffOnly()); + } + + @Test + public void testSendJoinNotificationSkipsWhenTemplateIsEmpty() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn(""); + + // Should not throw and should not attempt to send + discordWebhookService.sendJoinNotification("TestPlayer"); + } + + @Test + public void testSendJoinNotificationSkipsWhenTemplateIsNull() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn(null); + + // Should not throw and should not attempt to send + discordWebhookService.sendJoinNotification("TestPlayer"); + } + + @Test + public void testSendQuitNotificationSkipsWhenTemplateIsEmpty() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn(""); + + // Should not throw and should not attempt to send + discordWebhookService.sendQuitNotification("TestPlayer"); + } + + @Test + public void testSendQuitNotificationSkipsWhenTemplateIsNull() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn(null); + + // Should not throw and should not attempt to send + discordWebhookService.sendQuitNotification("TestPlayer"); + } + + @Test + public void testSendJoinNotificationSkipsWhenUrlIsEmpty() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); + when(configService.getString("discordWebhookUrl")).thenReturn(""); + + // Should not throw + discordWebhookService.sendJoinNotification("TestPlayer"); + } + + @Test + public void testSendQuitNotificationSkipsWhenUrlIsEmpty() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); + when(configService.getString("discordWebhookUrl")).thenReturn(""); + + // Should not throw + discordWebhookService.sendQuitNotification("TestPlayer"); + } + + @Test + public void testSendJoinNotificationHandlesInvalidUrl() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); + when(configService.getString("discordWebhookUrl")).thenReturn("not-a-valid-url"); + + // Should not throw - errors are logged gracefully + discordWebhookService.sendJoinNotification("TestPlayer"); + } + + @Test + public void testSendQuitNotificationHandlesInvalidUrl() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); + when(configService.getString("discordWebhookUrl")).thenReturn("not-a-valid-url"); + + // Should not throw - errors are logged gracefully + discordWebhookService.sendQuitNotification("TestPlayer"); + } +} From 877151a109b4cce83258344af5920f8a6f60543d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:47:20 +0000 Subject: [PATCH 3/5] Address code review: fix OutputStream resource handling and remove redundant cast Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../services/DiscordWebhookService.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java index f4154c7..9906ad8 100644 --- a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java +++ b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java @@ -87,11 +87,14 @@ private void sendWebhookMessage(String content) { connection.setDoOutput(true); String jsonPayload = "{\"content\": \"" + escapeJson(content) + "\"}"; + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); OutputStream os = connection.getOutputStream(); - byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); - os.write(input, 0, input.length); - os.close(); + try { + os.write(input, 0, input.length); + } finally { + os.close(); + } int responseCode = connection.getResponseCode(); if (responseCode < 200 || responseCode >= 300) { @@ -132,7 +135,7 @@ private String escapeJson(String text) { break; default: if (c < 0x20) { - sb.append(String.format("\\u%04x", (int) c)); + sb.append(String.format("\\u%04x", c)); } else { sb.append(c); } From fa9f28b807ffc08b124974c6e3df19cfa0ae2915 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:00:47 +0000 Subject: [PATCH 4/5] Address review comments: thread safety, URL trimming, isEnabled guard, connection cleanup, test verifications - Capture player.getName() before async task in JoinHandler and QuitHandler - Trim whitespace-only URLs in isEnabled() check - Add isEnabled() guard at start of sendJoinNotification/sendQuitNotification - Properly disconnect HttpURLConnection and consume response streams - Add Mockito verify() assertions to tests and whitespace URL test case - Use buffered read for response stream consumption Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../eventhandlers/JoinHandler.java | 3 +- .../eventhandlers/QuitHandler.java | 3 +- .../services/DiscordWebhookService.java | 37 ++++++++- .../services/DiscordWebhookServiceTest.java | 83 +++++++++++++------ 4 files changed, 95 insertions(+), 31 deletions(-) diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java index 387e5a2..c51c36f 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java @@ -60,10 +60,11 @@ private void sendDiscordJoinNotification(Player player) { if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { return; } + final String playerName = player.getName(); plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { @Override public void run() { - discordWebhookService.sendJoinNotification(player.getName()); + discordWebhookService.sendJoinNotification(playerName); } }); } diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java index 49112e0..47d1670 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java @@ -75,10 +75,11 @@ private void sendDiscordQuitNotification(Player player) { if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { return; } + final String playerName = player.getName(); plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { @Override public void run() { - discordWebhookService.sendQuitNotification(player.getName()); + discordWebhookService.sendQuitNotification(playerName); } }); } diff --git a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java index 9906ad8..a0bafa6 100644 --- a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java +++ b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java @@ -1,6 +1,7 @@ package dansplugins.activitytracker.services; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; @@ -30,7 +31,11 @@ public boolean isEnabled() { return false; } String url = configService.getString("discordWebhookUrl"); - return url != null && !url.isEmpty(); + if (url == null) { + return false; + } + String trimmed = url.trim(); + return !trimmed.isEmpty(); } /** @@ -46,6 +51,9 @@ public boolean isStaffOnly() { * @param playerName The name of the player who joined. */ public void sendJoinNotification(String playerName) { + if (!isEnabled()) { + return; + } String template = configService.getString("discordWebhookJoinMessage"); if (template == null || template.isEmpty()) { return; @@ -59,6 +67,9 @@ public void sendJoinNotification(String playerName) { * @param playerName The name of the player who quit. */ public void sendQuitNotification(String playerName) { + if (!isEnabled()) { + return; + } String template = configService.getString("discordWebhookQuitMessage"); if (template == null || template.isEmpty()) { return; @@ -73,13 +84,14 @@ public void sendQuitNotification(String playerName) { */ private void sendWebhookMessage(String content) { String webhookUrl = configService.getString("discordWebhookUrl"); - if (webhookUrl == null || webhookUrl.isEmpty()) { + if (webhookUrl == null || webhookUrl.trim().isEmpty()) { return; } + HttpURLConnection connection = null; try { - URL url = new URL(webhookUrl); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + URL url = new URL(webhookUrl.trim()); + connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); connection.setConnectTimeout(5000); @@ -100,8 +112,25 @@ private void sendWebhookMessage(String content) { if (responseCode < 200 || responseCode >= 300) { logger.log("Discord webhook returned error code: " + responseCode); } + + // Consume response stream to free up the connection + InputStream is = (responseCode >= 200 && responseCode < 300) + ? connection.getInputStream() + : connection.getErrorStream(); + if (is != null) { + try { + byte[] buffer = new byte[1024]; + while (is.read(buffer) != -1) { } + } finally { + is.close(); + } + } } catch (IOException e) { logger.log("Failed to send Discord webhook message: " + e.getMessage()); + } finally { + if (connection != null) { + connection.disconnect(); + } } } diff --git a/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java index 143aa7e..aef2610 100644 --- a/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java +++ b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java @@ -51,6 +51,14 @@ public void testIsEnabledReturnsFalseWhenUrlIsNull() { assertFalse(discordWebhookService.isEnabled()); } + @Test + public void testIsEnabledReturnsFalseWhenUrlIsWhitespaceOnly() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn(" "); + + assertFalse(discordWebhookService.isEnabled()); + } + @Test public void testIsEnabledReturnsTrueWhenConfigured() { when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); @@ -68,71 +76,96 @@ public void testIsStaffOnlyReturnsConfigValue() { assertFalse(discordWebhookService.isStaffOnly()); } + @Test + public void testSendJoinNotificationSkipsWhenDisabled() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); + + discordWebhookService.sendJoinNotification("TestPlayer"); + + // Should not attempt to read join message template when disabled + verify(configService, never()).getString("discordWebhookJoinMessage"); + } + + @Test + public void testSendQuitNotificationSkipsWhenDisabled() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); + + discordWebhookService.sendQuitNotification("TestPlayer"); + + // Should not attempt to read quit message template when disabled + verify(configService, never()).getString("discordWebhookQuitMessage"); + } + @Test public void testSendJoinNotificationSkipsWhenTemplateIsEmpty() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); when(configService.getString("discordWebhookJoinMessage")).thenReturn(""); - // Should not throw and should not attempt to send discordWebhookService.sendJoinNotification("TestPlayer"); + + // Should check the template but not attempt to fetch URL for sending + verify(configService).getString("discordWebhookJoinMessage"); + verify(configService, times(1)).getString("discordWebhookUrl"); } @Test public void testSendJoinNotificationSkipsWhenTemplateIsNull() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); when(configService.getString("discordWebhookJoinMessage")).thenReturn(null); - // Should not throw and should not attempt to send discordWebhookService.sendJoinNotification("TestPlayer"); + + verify(configService).getString("discordWebhookJoinMessage"); + verify(configService, times(1)).getString("discordWebhookUrl"); } @Test public void testSendQuitNotificationSkipsWhenTemplateIsEmpty() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); when(configService.getString("discordWebhookQuitMessage")).thenReturn(""); - // Should not throw and should not attempt to send discordWebhookService.sendQuitNotification("TestPlayer"); + + verify(configService).getString("discordWebhookQuitMessage"); + verify(configService, times(1)).getString("discordWebhookUrl"); } @Test public void testSendQuitNotificationSkipsWhenTemplateIsNull() { + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); + when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); when(configService.getString("discordWebhookQuitMessage")).thenReturn(null); - // Should not throw and should not attempt to send discordWebhookService.sendQuitNotification("TestPlayer"); - } - - @Test - public void testSendJoinNotificationSkipsWhenUrlIsEmpty() { - when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); - when(configService.getString("discordWebhookUrl")).thenReturn(""); - - // Should not throw - discordWebhookService.sendJoinNotification("TestPlayer"); - } - @Test - public void testSendQuitNotificationSkipsWhenUrlIsEmpty() { - when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); - when(configService.getString("discordWebhookUrl")).thenReturn(""); - - // Should not throw - discordWebhookService.sendQuitNotification("TestPlayer"); + verify(configService).getString("discordWebhookQuitMessage"); + verify(configService, times(1)).getString("discordWebhookUrl"); } @Test public void testSendJoinNotificationHandlesInvalidUrl() { - when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); when(configService.getString("discordWebhookUrl")).thenReturn("not-a-valid-url"); + when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); - // Should not throw - errors are logged gracefully discordWebhookService.sendJoinNotification("TestPlayer"); + + // Error should be logged gracefully + verify(logger).log(contains("Failed to send Discord webhook message")); } @Test public void testSendQuitNotificationHandlesInvalidUrl() { - when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); + when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); when(configService.getString("discordWebhookUrl")).thenReturn("not-a-valid-url"); + when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); - // Should not throw - errors are logged gracefully discordWebhookService.sendQuitNotification("TestPlayer"); + + // Error should be logged gracefully + verify(logger).log(contains("Failed to send Discord webhook message")); } } From f6f897b374206f3f547065d149dfe197b2738eac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:46:40 +0000 Subject: [PATCH 5/5] Fix config not appearing for existing servers; refactor async-safety; update tests - Always call saveMissingConfigDefaultsIfNotPresent() so new config keys (Discord webhook settings) are written to existing config.yml files - Refactor DiscordWebhookService: separate config reading (main thread) from HTTP sending (async task) via getWebhookUrl(), prepareJoinMessage(), prepareQuitMessage(), sendWebhookMessage(url, content) - Update JoinHandler/QuitHandler to snapshot all config on main thread - Use MockitoAnnotations.openMocks() instead of deprecated initMocks() - Tests updated for new decomposed API Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../activitytracker/ActivityTracker.java | 18 +-- .../eventhandlers/JoinHandler.java | 8 +- .../eventhandlers/QuitHandler.java | 8 +- .../services/DiscordWebhookService.java | 56 +++++---- .../services/DiscordWebhookServiceTest.java | 112 ++++++++++-------- 5 files changed, 108 insertions(+), 94 deletions(-) diff --git a/src/main/java/dansplugins/activitytracker/ActivityTracker.java b/src/main/java/dansplugins/activitytracker/ActivityTracker.java index b5518bc..54ff950 100644 --- a/src/main/java/dansplugins/activitytracker/ActivityTracker.java +++ b/src/main/java/dansplugins/activitytracker/ActivityTracker.java @@ -1,6 +1,5 @@ package dansplugins.activitytracker; -import java.io.File; import java.util.ArrayList; import java.util.Arrays; @@ -124,22 +123,7 @@ public boolean isDebugEnabled() { } private void initializeConfig() { - if (configFileExists()) { - performCompatibilityChecks(); - } - else { - configService.saveMissingConfigDefaultsIfNotPresent(); - } - } - - private boolean configFileExists() { - return new File("./plugins/" + getName() + "/config.yml").exists(); - } - - private void performCompatibilityChecks() { - if (isVersionMismatched()) { - configService.saveMissingConfigDefaultsIfNotPresent(); - } + configService.saveMissingConfigDefaultsIfNotPresent(); } /** diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java index c51c36f..64dfb1d 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/JoinHandler.java @@ -60,11 +60,15 @@ private void sendDiscordJoinNotification(Player player) { if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { return; } - final String playerName = player.getName(); + final String webhookUrl = discordWebhookService.getWebhookUrl(); + final String message = discordWebhookService.prepareJoinMessage(player.getName()); + if (webhookUrl == null || message == null) { + return; + } plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { @Override public void run() { - discordWebhookService.sendJoinNotification(playerName); + discordWebhookService.sendWebhookMessage(webhookUrl, message); } }); } diff --git a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java index 47d1670..d64244e 100644 --- a/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java +++ b/src/main/java/dansplugins/activitytracker/eventhandlers/QuitHandler.java @@ -75,11 +75,15 @@ private void sendDiscordQuitNotification(Player player) { if (discordWebhookService.isStaffOnly() && !player.hasPermission(STAFF_PERMISSION)) { return; } - final String playerName = player.getName(); + final String webhookUrl = discordWebhookService.getWebhookUrl(); + final String message = discordWebhookService.prepareQuitMessage(player.getName()); + if (webhookUrl == null || message == null) { + return; + } plugin.getServer().getScheduler().runTaskAsynchronously(plugin, new Runnable() { @Override public void run() { - discordWebhookService.sendQuitNotification(playerName); + discordWebhookService.sendWebhookMessage(webhookUrl, message); } }); } diff --git a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java index a0bafa6..fb173b5 100644 --- a/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java +++ b/src/main/java/dansplugins/activitytracker/services/DiscordWebhookService.java @@ -24,6 +24,7 @@ public DiscordWebhookService(ConfigService configService, Logger logger) { /** * Checks if the Discord webhook feature is enabled and configured. + * Must be called from the main server thread. * @return true if enabled and a webhook URL is set. */ public boolean isEnabled() { @@ -40,6 +41,7 @@ public boolean isEnabled() { /** * Checks if webhooks should only fire for staff members. + * Must be called from the main server thread. * @return true if staff-only mode is active. */ public boolean isStaffOnly() { @@ -47,50 +49,62 @@ public boolean isStaffOnly() { } /** - * Sends a player join notification to the configured Discord webhook. - * @param playerName The name of the player who joined. + * Returns the configured webhook URL, trimmed. + * Must be called from the main server thread. + * @return the trimmed webhook URL, or null if not configured. */ - public void sendJoinNotification(String playerName) { - if (!isEnabled()) { - return; + public String getWebhookUrl() { + String url = configService.getString("discordWebhookUrl"); + if (url == null) { + return null; } + String trimmed = url.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * Prepares a join notification message by applying the player name to the configured template. + * Must be called from the main server thread. + * @param playerName The name of the player who joined. + * @return The formatted message, or null if the template is empty/null. + */ + public String prepareJoinMessage(String playerName) { String template = configService.getString("discordWebhookJoinMessage"); if (template == null || template.isEmpty()) { - return; + return null; } - String message = template.replace("{player}", playerName); - sendWebhookMessage(message); + return template.replace("{player}", playerName); } /** - * Sends a player quit notification to the configured Discord webhook. + * Prepares a quit notification message by applying the player name to the configured template. + * Must be called from the main server thread. * @param playerName The name of the player who quit. + * @return The formatted message, or null if the template is empty/null. */ - public void sendQuitNotification(String playerName) { - if (!isEnabled()) { - return; - } + public String prepareQuitMessage(String playerName) { String template = configService.getString("discordWebhookQuitMessage"); if (template == null || template.isEmpty()) { - return; + return null; } - String message = template.replace("{player}", playerName); - sendWebhookMessage(message); + return template.replace("{player}", playerName); } /** - * Sends a message to the configured Discord webhook URL. + * Sends a message to the specified Discord webhook URL. + * This method performs HTTP I/O and should be called from an async task. + * Does not access Bukkit APIs. + * @param webhookUrl The Discord webhook URL to post to. * @param content The message content to send. */ - private void sendWebhookMessage(String content) { - String webhookUrl = configService.getString("discordWebhookUrl"); - if (webhookUrl == null || webhookUrl.trim().isEmpty()) { + public void sendWebhookMessage(String webhookUrl, String content) { + if (webhookUrl == null || webhookUrl.isEmpty()) { return; } HttpURLConnection connection = null; try { - URL url = new URL(webhookUrl.trim()); + URL url = new URL(webhookUrl); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); diff --git a/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java index aef2610..27b0816 100644 --- a/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java +++ b/src/test/java/dansplugins/activitytracker/services/DiscordWebhookServiceTest.java @@ -1,6 +1,7 @@ package dansplugins.activitytracker.services; import dansplugins.activitytracker.utils.Logger; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -21,13 +22,19 @@ public class DiscordWebhookServiceTest { private Logger logger; private DiscordWebhookService discordWebhookService; + private AutoCloseable mocks; @Before public void setUp() { - MockitoAnnotations.initMocks(this); + mocks = MockitoAnnotations.openMocks(this); discordWebhookService = new DiscordWebhookService(configService, logger); } + @After + public void tearDown() throws Exception { + mocks.close(); + } + @Test public void testIsEnabledReturnsFalseWhenDisabled() { when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); @@ -77,93 +84,94 @@ public void testIsStaffOnlyReturnsConfigValue() { } @Test - public void testSendJoinNotificationSkipsWhenDisabled() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); + public void testGetWebhookUrlReturnsTrimmedUrl() { + when(configService.getString("discordWebhookUrl")).thenReturn(" https://discord.com/api/webhooks/test "); - discordWebhookService.sendJoinNotification("TestPlayer"); + assertEquals("https://discord.com/api/webhooks/test", discordWebhookService.getWebhookUrl()); + } - // Should not attempt to read join message template when disabled - verify(configService, never()).getString("discordWebhookJoinMessage"); + @Test + public void testGetWebhookUrlReturnsNullWhenEmpty() { + when(configService.getString("discordWebhookUrl")).thenReturn(""); + + assertNull(discordWebhookService.getWebhookUrl()); } @Test - public void testSendQuitNotificationSkipsWhenDisabled() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(false); + public void testGetWebhookUrlReturnsNullWhenNull() { + when(configService.getString("discordWebhookUrl")).thenReturn(null); + + assertNull(discordWebhookService.getWebhookUrl()); + } - discordWebhookService.sendQuitNotification("TestPlayer"); + @Test + public void testGetWebhookUrlReturnsNullWhenWhitespaceOnly() { + when(configService.getString("discordWebhookUrl")).thenReturn(" "); - // Should not attempt to read quit message template when disabled - verify(configService, never()).getString("discordWebhookQuitMessage"); + assertNull(discordWebhookService.getWebhookUrl()); } @Test - public void testSendJoinNotificationSkipsWhenTemplateIsEmpty() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); - when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); - when(configService.getString("discordWebhookJoinMessage")).thenReturn(""); + public void testPrepareJoinMessageReplacesPlayerName() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); - discordWebhookService.sendJoinNotification("TestPlayer"); + assertEquals("**TestPlayer** joined!", discordWebhookService.prepareJoinMessage("TestPlayer")); + } - // Should check the template but not attempt to fetch URL for sending - verify(configService).getString("discordWebhookJoinMessage"); - verify(configService, times(1)).getString("discordWebhookUrl"); + @Test + public void testPrepareJoinMessageReturnsNullWhenTemplateIsEmpty() { + when(configService.getString("discordWebhookJoinMessage")).thenReturn(""); + + assertNull(discordWebhookService.prepareJoinMessage("TestPlayer")); } @Test - public void testSendJoinNotificationSkipsWhenTemplateIsNull() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); - when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); + public void testPrepareJoinMessageReturnsNullWhenTemplateIsNull() { when(configService.getString("discordWebhookJoinMessage")).thenReturn(null); - discordWebhookService.sendJoinNotification("TestPlayer"); + assertNull(discordWebhookService.prepareJoinMessage("TestPlayer")); + } + + @Test + public void testPrepareQuitMessageReplacesPlayerName() { + when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); - verify(configService).getString("discordWebhookJoinMessage"); - verify(configService, times(1)).getString("discordWebhookUrl"); + assertEquals("**TestPlayer** left.", discordWebhookService.prepareQuitMessage("TestPlayer")); } @Test - public void testSendQuitNotificationSkipsWhenTemplateIsEmpty() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); - when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); + public void testPrepareQuitMessageReturnsNullWhenTemplateIsEmpty() { when(configService.getString("discordWebhookQuitMessage")).thenReturn(""); - discordWebhookService.sendQuitNotification("TestPlayer"); - - verify(configService).getString("discordWebhookQuitMessage"); - verify(configService, times(1)).getString("discordWebhookUrl"); + assertNull(discordWebhookService.prepareQuitMessage("TestPlayer")); } @Test - public void testSendQuitNotificationSkipsWhenTemplateIsNull() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); - when(configService.getString("discordWebhookUrl")).thenReturn("https://discord.com/api/webhooks/test"); + public void testPrepareQuitMessageReturnsNullWhenTemplateIsNull() { when(configService.getString("discordWebhookQuitMessage")).thenReturn(null); - discordWebhookService.sendQuitNotification("TestPlayer"); - - verify(configService).getString("discordWebhookQuitMessage"); - verify(configService, times(1)).getString("discordWebhookUrl"); + assertNull(discordWebhookService.prepareQuitMessage("TestPlayer")); } @Test - public void testSendJoinNotificationHandlesInvalidUrl() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); - when(configService.getString("discordWebhookUrl")).thenReturn("not-a-valid-url"); - when(configService.getString("discordWebhookJoinMessage")).thenReturn("**{player}** joined!"); - - discordWebhookService.sendJoinNotification("TestPlayer"); + public void testSendWebhookMessageSkipsWhenUrlIsNull() { + discordWebhookService.sendWebhookMessage(null, "test message"); - // Error should be logged gracefully - verify(logger).log(contains("Failed to send Discord webhook message")); + // No HTTP call should be attempted, no errors logged + verifyNoInteractions(logger); } @Test - public void testSendQuitNotificationHandlesInvalidUrl() { - when(configService.getBoolean("discordWebhookEnabled")).thenReturn(true); - when(configService.getString("discordWebhookUrl")).thenReturn("not-a-valid-url"); - when(configService.getString("discordWebhookQuitMessage")).thenReturn("**{player}** left."); + public void testSendWebhookMessageSkipsWhenUrlIsEmpty() { + discordWebhookService.sendWebhookMessage("", "test message"); - discordWebhookService.sendQuitNotification("TestPlayer"); + // No HTTP call should be attempted, no errors logged + verifyNoInteractions(logger); + } + + @Test + public void testSendWebhookMessageHandlesInvalidUrl() { + discordWebhookService.sendWebhookMessage("not-a-valid-url", "**TestPlayer** joined!"); // Error should be logged gracefully verify(logger).log(contains("Failed to send Discord webhook message"));