diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 495c41d..e05ea45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,9 @@ jobs: - name: Run unit tests and build run: mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false + - name: Verify coverage report + run: test -s target/site/jacoco/jacoco.xml + - name: Verify runtime JAR env: ARTIFACT_PATH: ${{ steps.dev.outputs.jar }} @@ -74,3 +77,11 @@ jobs: name: unit-test-reports-${{ github.run_id }}-${{ github.run_attempt }} path: target/surefire-reports/ if-no-files-found: error + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index 87d147c..eb48f44 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -73,6 +73,17 @@ jobs: echo "jar=target/$final_name.jar" >> "$GITHUB_OUTPUT" mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false + - name: Verify coverage report + run: test -s target/site/jacoco/jacoco.xml + + - name: Upload coverage report + if: ${{ !cancelled() && hashFiles('target/site/jacoco/jacoco.xml') != '' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ github.run_id }}-${{ github.run_attempt }} + path: target/site/jacoco/ + if-no-files-found: error + - name: Stage only the release JAR env: ARTIFACT_PATH: ${{ steps.maven.outputs.jar }} diff --git a/README.md b/README.md index ab6db67..9253a97 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,7 @@ Copyright (c) 2026 TF-Minecraft contributors. TF-Minecraft-authored material in this repository is licensed under the [Artistic License 2.0](LICENSE). Third-party dependencies and bundled material retain their own licenses. + +## Tests and coverage + +Run `mvn clean verify` with Java 21 after installing the pinned dependencies. JaCoCo checks 100% executable runtime line coverage with no production-code exclusions. Instruction and branch coverage are reported separately in `target/site/jacoco/`; CI uploads the report for every build and release. diff --git a/pom.xml b/pom.xml index fddbb8f..86bee19 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ 21 UTF-8 full + 2.0.0 @@ -44,13 +45,13 @@ org.junit.jupiter junit-jupiter - 5.10.2 + 5.14.0 test org.mockito mockito-core - 5.14.2 + 5.23.0 test @@ -106,14 +107,44 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.4 - -javaagent:${org.mockito:mockito-core:jar} -Xshare:off - - io.papermc.paper:paper-api - + @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off + true + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + prepare-agent + + coverage-report + verify + report + + + coverage-check + verify + check + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + + + + + + diff --git a/src/main/java/net/tfminecraft/permcleaner/command/PermCleanerCommand.java b/src/main/java/net/tfminecraft/permcleaner/command/PermCleanerCommand.java index 35b317e..17b4ab7 100644 --- a/src/main/java/net/tfminecraft/permcleaner/command/PermCleanerCommand.java +++ b/src/main/java/net/tfminecraft/permcleaner/command/PermCleanerCommand.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Locale; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import java.util.stream.Collectors; import org.bukkit.Bukkit; @@ -14,6 +16,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; +import org.bukkit.plugin.IllegalPluginAccessException; import net.tfminecraft.permcleaner.PermCleaner; import net.tfminecraft.permcleaner.lp.CleanResult; @@ -43,10 +46,10 @@ public boolean onCommand(CommandSender sender, Command command, String label, St String sub = args[0].toLowerCase(Locale.ROOT); switch (sub) { case "reload" -> reload(sender); - case "status" -> status(sender, args); - case "inspect" -> inspect(sender, args); - case "clean" -> clean(sender, args, false); - case "force" -> clean(sender, args, true); + case "status" -> resolveTarget(sender, args, target -> status(sender, target)); + case "inspect" -> resolveTarget(sender, args, target -> inspect(sender, target)); + case "clean" -> resolveTarget(sender, args, target -> clean(sender, target, false)); + case "force" -> resolveTarget(sender, args, target -> clean(sender, target, true)); default -> sender.sendMessage(ChatColor.RED + USAGE); } return true; @@ -64,11 +67,7 @@ private void reload(CommandSender sender) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private void status(CommandSender sender, String[] args) { - Target target = resolveTarget(sender, args); - if (target == null) { - return; - } + private void status(CommandSender sender, Target target) { String stored = plugin.stamps() == null ? null : plugin.stamps().getSeason(target.uuid).orElse(null); @@ -86,11 +85,7 @@ private void status(CommandSender sender, String[] args) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private void inspect(CommandSender sender, String[] args) { - Target target = resolveTarget(sender, args); - if (target == null) { - return; - } + private void inspect(CommandSender sender, Target target) { sender.sendMessage(ChatColor.GREEN + "Inspecting " + ChatColor.WHITE + target.name + ChatColor.GREEN + "..."); plugin.cleaner().inspect(target.uuid, result -> { if (!sender.equals(Bukkit.getConsoleSender()) && sender instanceof Player player && !player.isOnline()) { @@ -120,9 +115,9 @@ private void inspect(CommandSender sender, String[] args) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private void clean(CommandSender sender, String[] args, boolean force) { - Target target = resolveTarget(sender, args); - if (target == null) { + private void clean(CommandSender sender, Target target, boolean force) { + if (plugin.seasonId().isBlank()) { + sender.sendMessage(ChatColor.RED + "Cleaning is disabled: season-id is blank."); return; } if (!force && plugin.stamps() != null && !plugin.stamps().needsClean(target.uuid, plugin.seasonId())) { @@ -148,26 +143,63 @@ private void clean(CommandSender sender, String[] args, boolean force) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private Target resolveTarget(CommandSender sender, String[] args) { + private void resolveTarget(CommandSender sender, String[] args, Consumer resolved) { if (args.length < 2) { if (sender instanceof Player player) { - return new Target(player.getUniqueId(), player.getName()); + resolved.accept(new Target(player.getUniqueId(), player.getName())); + } else { + sender.sendMessage(ChatColor.RED + "Console must specify a player."); } - sender.sendMessage(ChatColor.RED + "Console must specify a player."); - return null; + return; + } + String name = args[1]; + if (name.isBlank()) { + sender.sendMessage(ChatColor.RED + "Unknown player " + name); + return; } - Player online = Bukkit.getPlayerExact(args[1]); + Player online = Bukkit.getPlayerExact(name); if (online != null) { - return new Target(online.getUniqueId(), online.getName()); + resolved.accept(new Target(online.getUniqueId(), online.getName())); + return; } - @SuppressWarnings("deprecation") - OfflinePlayer offline = Bukkit.getOfflinePlayer(args[1]); + OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(name); + if (cached != null) { + resolveOffline(sender, name, cached, resolved); + return; + } + + try { + // Paper's resolver also honors proxy online-mode and offline UUID policy. + CompletableFuture.supplyAsync(() -> Bukkit.getPlayerUniqueId(name), + task -> Bukkit.getScheduler().runTaskAsynchronously(plugin, task)).whenComplete((uuid, failure) -> { + if (!plugin.isEnabled()) return; + try { + Bukkit.getScheduler().runTask(plugin, () -> { + if (!plugin.isEnabled() || (sender instanceof Player player && !player.isOnline())) return; + if (failure != null) { + sender.sendMessage(ChatColor.RED + "Player lookup failed for " + name + ". Please try again."); + } else if (uuid == null) { + sender.sendMessage(ChatColor.RED + "Unknown player " + name); + } else { + resolveOffline(sender, name, Bukkit.getOfflinePlayer(uuid), resolved); + } + }); + } catch (IllegalPluginAccessException ignored) { + // The plugin was disabled between completing the lookup and scheduling the result. + } + }); + } catch (IllegalPluginAccessException ignored) { + // The plugin was disabled before the lookup could be submitted. + } + } + + @SuppressWarnings("deprecation") + private void resolveOffline(CommandSender sender, String name, OfflinePlayer offline, Consumer resolved) { if (offline.hasPlayedBefore() && offline.getUniqueId() != null) { - String name = offline.getName() != null ? offline.getName() : args[1]; - return new Target(offline.getUniqueId(), name); + resolved.accept(new Target(offline.getUniqueId(), offline.getName() != null ? offline.getName() : name)); + } else { + sender.sendMessage(ChatColor.RED + "Unknown player " + name); } - sender.sendMessage(ChatColor.RED + "Unknown player " + args[1]); - return null; } @Override diff --git a/src/main/java/net/tfminecraft/permcleaner/lp/SeasonCleanService.java b/src/main/java/net/tfminecraft/permcleaner/lp/SeasonCleanService.java index 2ce4342..44152c7 100644 --- a/src/main/java/net/tfminecraft/permcleaner/lp/SeasonCleanService.java +++ b/src/main/java/net/tfminecraft/permcleaner/lp/SeasonCleanService.java @@ -25,7 +25,7 @@ public SeasonCleanService(PermCleaner plugin) { } public void consider(Player player, World world) { - if (player == null || !plugin.isEnabled()) { + if (player == null || !plugin.isEnabled() || plugin.seasonId().isBlank()) { return; } if (!worldMatches(world)) { @@ -50,91 +50,112 @@ public boolean worldMatches(World world) { } /** - * @return false if a clean is already running for this uuid + * @return false if disabled, the season is blank, the uuid is null, or a clean is already running */ public boolean startClean(UUID uuid, String name, Consumer onMain) { if (uuid == null || !plugin.isEnabled()) { return false; } - if (!inFlight.add(uuid)) { + String seasonId = plugin.seasonId(); + if (seasonId.isBlank() || !inFlight.add(uuid)) { return false; } - String seasonId = plugin.seasonId(); - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> cleanAsync(uuid, name, seasonId, onMain)); - return true; + try { + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> cleanAsync(uuid, name, seasonId, onMain)); + return true; + } catch (RuntimeException | Error failure) { + inFlight.remove(uuid); + throw failure; + } } public void inspect(UUID uuid, Consumer onMain) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + CleanResult result; try { LuckPerms api = LuckPermsProvider.get(); User user = api.getUserManager().loadUser(uuid).join(); if (user == null) { throw new IllegalStateException("loadUser returned null for " + uuid); } - CleanResult result = UserPermissionCleaner.inspect(user, plugin.keepList()); - runMain(() -> { - if (onMain != null) { - onMain.accept(result); - } - }); + result = UserPermissionCleaner.inspect(user, plugin.keepList()); } catch (Exception e) { plugin.getLogger().log(Level.WARNING, "Failed to inspect permissions for " + uuid, e); - runMain(() -> { - if (onMain != null) { - onMain.accept(null); - } - }); + result = null; } + CleanResult delivered = result; + runMain(() -> { + if (onMain != null) { + onMain.accept(delivered); + } + }); }); } private void cleanAsync(UUID uuid, String name, String seasonId, Consumer onMain) { + CleanResult result; try { LuckPerms api = LuckPermsProvider.get(); User user = api.getUserManager().loadUser(uuid).join(); if (user == null) { throw new IllegalStateException("loadUser returned null for " + uuid); } - CleanResult result = UserPermissionCleaner.apply(user, plugin.keepList()); - if (result.changed()) { - api.getUserManager().saveUser(user).join(); - } - if (!plugin.isEnabled()) { - inFlight.remove(uuid); - runMain(() -> { - if (onMain != null) { - onMain.accept(null); - } - }); - return; - } - Bukkit.getScheduler().runTask(plugin, () -> stampAndFinish(uuid, name, seasonId, result, onMain)); + result = UserPermissionCleaner.apply(user, plugin.keepList()); + // A previous failed save may have left this loaded user already mutated. + // Persist even a no-op retry before recording the season as complete. + api.getUserManager().saveUser(user).join(); } catch (Exception e) { + notifyFailure(uuid, onMain, e); plugin.getLogger().log(Level.WARNING, "Failed to clean permissions for " + name, e); - inFlight.remove(uuid); - runMain(() -> { - if (onMain != null) { - onMain.accept(null); - } - }); + return; + } catch (Error failure) { + notifyFailure(uuid, onMain, failure); + throw failure; + } + if (!plugin.isEnabled()) { + finishFailed(uuid, onMain); + return; + } + try { + Bukkit.getScheduler().runTask(plugin, () -> stampAndFinish(uuid, name, seasonId, result, onMain)); + } catch (RuntimeException | Error failure) { + notifyFailure(uuid, onMain, failure); + throw failure; } } + private void notifyFailure(UUID uuid, Consumer onMain, Throwable failure) { + try { + finishFailed(uuid, onMain); + } catch (RuntimeException | Error notificationFailure) { + if (notificationFailure != failure) failure.addSuppressed(notificationFailure); + } + } + + private void finishFailed(UUID uuid, Consumer onMain) { + inFlight.remove(uuid); + runMain(() -> { + if (onMain != null) { + onMain.accept(null); + } + }); + } + private void stampAndFinish(UUID uuid, String name, String seasonId, CleanResult result, Consumer onMain) { try { - if (plugin.isEnabled() && plugin.stamps() != null) { - plugin.stamps().setSeason(uuid, seasonId); - plugin.getLogger().info(name + " season " + seasonId + " removed " + result.removed()); - } - if (onMain != null) { - onMain.accept(result); + CleanResult delivered = result; + try { + if (plugin.isEnabled() && plugin.stamps() != null) { + plugin.stamps().setSeason(uuid, seasonId); + plugin.getLogger().info(name + " season " + seasonId + " removed " + result.removed()); + } + } catch (Exception e) { + plugin.getLogger().log(Level.WARNING, "Failed to stamp season for " + name, e); + delivered = null; } - } catch (Exception e) { - plugin.getLogger().log(Level.WARNING, "Failed to stamp season for " + name, e); if (onMain != null) { - onMain.accept(null); + onMain.accept(delivered); } } finally { inFlight.remove(uuid); diff --git a/src/test/java/net/tfminecraft/permcleaner/PermCleanerTest.java b/src/test/java/net/tfminecraft/permcleaner/PermCleanerTest.java new file mode 100644 index 0000000..4f22009 --- /dev/null +++ b/src/test/java/net/tfminecraft/permcleaner/PermCleanerTest.java @@ -0,0 +1,279 @@ +package net.tfminecraft.permcleaner; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.UnsafeValues; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import io.papermc.paper.plugin.configuration.PluginMeta; +import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; +import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; + +import net.tfminecraft.permcleaner.command.PermCleanerCommand; +import net.tfminecraft.permcleaner.listener.SeasonCleanListener; + +class PermCleanerTest { + + @TempDir + Path dataFolder; + + private PermCleaner plugin; + private YamlConfiguration config; + private Logger logger; + private PluginManager plugins; + private Plugin luckPerms; + private PluginCommand command; + + @BeforeEach + void setUp() throws IOException { + // JavaPlugin can only be constructed by the server's plugin loader, so run the real methods on a mock. + plugin = mock(PermCleaner.class, withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); + config = bundledConfig(); + logger = mock(Logger.class); + plugins = mock(PluginManager.class); + luckPerms = mock(Plugin.class); + command = mock(PluginCommand.class); + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(plugins); + when(plugins.getPlugin("LuckPerms")).thenReturn(luckPerms); + when(luckPerms.isEnabled()).thenReturn(true); + + doReturn(config).when(plugin).getConfig(); + doNothing().when(plugin).saveDefaultConfig(); + doReturn(logger).when(plugin).getLogger(); + doReturn(server).when(plugin).getServer(); + doReturn(dataFolder.toFile()).when(plugin).getDataFolder(); + doReturn(command).when(plugin).getCommand("permcleaner"); + } + + @AfterEach + void tearDown() { + plugin.onDisable(); + } + + @Test + void constructsUnderPluginClassLoader() throws Exception { + try (PluginLoader loader = new PluginLoader(); + MockedStatic bukkit = Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getUnsafe).thenReturn(mock(UnsafeValues.class)); + + Object instance = loader.loadClass(PermCleaner.class.getName()).getConstructor().newInstance(); + + assertSame(instance, loader.getPlugin()); + assertSame(loader, instance.getClass().getClassLoader()); + } + } + + @Test + void enableWiresServices() { + plugin.onEnable(); + + assertSame(plugin, PermCleaner.getInstance()); + assertNotNull(plugin.stamps()); + assertNotNull(plugin.cleaner()); + assertTrue(plugin.keepList().keeps("rulequiz.completed")); + assertTrue(Files.exists(dataFolder.resolve("stamps.db"))); + verify(plugin).saveDefaultConfig(); + verify(plugins).registerEvents(any(SeasonCleanListener.class), eq(plugin)); + verify(command).setExecutor(any(PermCleanerCommand.class)); + verify(command).setTabCompleter(any(PermCleanerCommand.class)); + verify(logger).info("season-id=vardera world=lobby"); + verify(logger, never()).warning(anyString()); + verify(plugins, never()).disablePlugin(any()); + } + + @Test + void disableClosesStampsAndClearsState() { + plugin.onEnable(); + plugin.onDisable(); + + assertNull(plugin.stamps()); + assertNull(plugin.cleaner()); + assertNull(PermCleaner.getInstance()); + + plugin.onDisable(); + assertNull(plugin.stamps()); + } + + @Test + void enableStopsWithoutLuckPerms() { + when(plugins.getPlugin("LuckPerms")).thenReturn(null); + + plugin.onEnable(); + + verify(logger).severe("LuckPerms is missing or disabled"); + verify(plugins).disablePlugin(plugin); + assertNull(plugin.stamps()); + assertNull(plugin.cleaner()); + verify(plugins, never()).registerEvents(any(), any()); + } + + @Test + void enableStopsWhenLuckPermsDisabled() { + when(luckPerms.isEnabled()).thenReturn(false); + + plugin.onEnable(); + + verify(logger).severe("LuckPerms is missing or disabled"); + verify(plugins).disablePlugin(plugin); + assertNull(plugin.stamps()); + } + + @Test + void enableStopsWhenStampsCannotOpen() throws IOException { + Files.createDirectory(dataFolder.resolve("stamps.db")); + + plugin.onEnable(); + + verify(logger).severe(startsWith("Failed to open stamps.db: ")); + verify(plugins).disablePlugin(plugin); + assertNull(plugin.stamps()); + assertNull(plugin.cleaner()); + verify(command, never()).setExecutor(any()); + } + + @Test + void settingsAreTrimmed() { + config.set("season-id", " season-2 "); + config.set("world", " Lobby "); + + assertEquals("season-2", plugin.seasonId()); + assertEquals("Lobby", plugin.worldName()); + assertEquals("Lobby", plugin.displayWorld()); + } + + @Test + void missingSettingsUseDefaults() { + config.set("season-id", null); + config.set("world", null); + + assertEquals("vardera", plugin.seasonId()); + assertEquals("", plugin.worldName()); + assertEquals("(any)", plugin.displayWorld()); + } + + @Test + void nullSettingsAreBlank() { + FileConfiguration empty = mock(FileConfiguration.class); + doReturn(empty).when(plugin).getConfig(); + + assertEquals("", plugin.seasonId()); + assertEquals("", plugin.worldName()); + assertEquals("(any)", plugin.displayWorld()); + } + + @Test + void reloadKeepListReadsConfig() { + config.set("keep", List.of("professions.*")); + + plugin.reloadKeepList(); + + assertTrue(plugin.keepList().keeps("professions.vegetables")); + assertFalse(plugin.keepList().keeps("group.default")); + verify(logger, never()).warning(anyString()); + } + + @Test + void emptyKeepListWarns() { + config.set("keep", List.of()); + + plugin.reloadKeepList(); + + assertTrue(plugin.keepList().isEmpty()); + verify(logger).warning("keep list is empty; every user permission node will be removed on clean"); + } + + private static YamlConfiguration bundledConfig() throws IOException { + try (InputStream in = PermCleanerTest.class.getResourceAsStream("/config.yml")) { + assertNotNull(in, "config.yml resource"); + return YamlConfiguration.loadConfiguration(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + } + + /** Loads PermCleaner itself child-first, as Paper does, and delegates everything else. */ + private static final class PluginLoader extends URLClassLoader implements ConfiguredPluginClassLoader { + private JavaPlugin plugin; + + PluginLoader() { + super(new URL[] { PermCleaner.class.getProtectionDomain().getCodeSource().getLocation() }, + PermCleanerTest.class.getClassLoader()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(PermCleaner.class.getName())) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + return loaded != null ? loaded : findClass(name); + } + } + + @Override + public Class loadClass(String name, boolean resolve, boolean checkGlobal, boolean checkLibraries) + throws ClassNotFoundException { + return loadClass(name, resolve); + } + + @Override + public void init(JavaPlugin plugin) { + this.plugin = plugin; + } + + @Override + public JavaPlugin getPlugin() { + return plugin; + } + + @Override + public PluginMeta getConfiguration() { + return null; + } + + @Override + public PluginClassLoaderGroup getGroup() { + return null; + } + } +} diff --git a/src/test/java/net/tfminecraft/permcleaner/command/PermCleanerCommandTest.java b/src/test/java/net/tfminecraft/permcleaner/command/PermCleanerCommandTest.java new file mode 100644 index 0000000..212533b --- /dev/null +++ b/src/test/java/net/tfminecraft/permcleaner/command/PermCleanerCommandTest.java @@ -0,0 +1,563 @@ +package net.tfminecraft.permcleaner.command; + +import static org.bukkit.ChatColor.GRAY; +import static org.bukkit.ChatColor.GREEN; +import static org.bukkit.ChatColor.RED; +import static org.bukkit.ChatColor.WHITE; +import static org.bukkit.ChatColor.YELLOW; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Deque; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.plugin.IllegalPluginAccessException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import net.tfminecraft.permcleaner.PermCleaner; +import net.tfminecraft.permcleaner.lp.CleanResult; +import net.tfminecraft.permcleaner.lp.CleanResult.NodeReport; +import net.tfminecraft.permcleaner.lp.SeasonCleanService; +import net.tfminecraft.permcleaner.store.SeasonStampStore; + +// Messages use the plugin's legacy colour codes. +@SuppressWarnings("deprecation") +class PermCleanerCommandTest { + + private static final String USAGE = RED + "/permcleaner [player]"; + private static final UUID ALEX = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID BEA = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + private PermCleaner plugin; + private SeasonCleanService cleaner; + private SeasonStampStore stamps; + private ConsoleCommandSender console; + private Player alex; + private Player bea; + private World world; + private Command command; + private MockedStatic bukkit; + private PermCleanerCommand executor; + private BukkitScheduler scheduler; + private final Deque mainTasks = new ArrayDeque<>(); + private final Deque asyncTasks = new ArrayDeque<>(); + + @BeforeEach + void setUp() { + plugin = mock(PermCleaner.class); + cleaner = mock(SeasonCleanService.class); + stamps = mock(SeasonStampStore.class); + when(plugin.cleaner()).thenReturn(cleaner); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.stamps()).thenReturn(stamps); + when(plugin.seasonId()).thenReturn("vardera"); + when(plugin.displayWorld()).thenReturn("lobby"); + when(stamps.getSeason(any())).thenReturn(Optional.empty()); + when(stamps.needsClean(any(), eq("vardera"))).thenReturn(true); + when(cleaner.startClean(any(), anyString(), any())).thenReturn(true); + + console = mock(ConsoleCommandSender.class); + world = mock(World.class); + alex = player(ALEX, "Alex"); + bea = player(BEA, "Bea"); + command = mock(Command.class); + + bukkit = Mockito.mockStatic(Bukkit.class); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> Bukkit.getPlayerExact("Bea")).thenReturn(bea); + bukkit.when(() -> Bukkit.getPlayer(BEA)).thenReturn(bea); + bukkit.when(() -> Bukkit.getPlayer(ALEX)).thenReturn(alex); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(anyString())).thenReturn(mock(OfflinePlayer.class)); + bukkit.when(Bukkit::getOnlineMode).thenReturn(true); + scheduler = mock(BukkitScheduler.class); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + mainTasks.add(invocation.getArgument(1)); return null; + }); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + asyncTasks.add(invocation.getArgument(1)); return null; + }); + bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(invocation -> List.of(alex, bea)); + + executor = new PermCleanerCommand(plugin); + } + + @AfterEach + void tearDown() { + bukkit.close(); + } + + @Test + void noArgumentsShowsUsage() { + assertTrue(run(console)); + assertEquals(List.of(USAGE), messages(console)); + } + + @Test + void unknownSubcommandShowsUsage() { + assertTrue(run(console, "purge")); + assertEquals(List.of(USAGE), messages(console)); + } + + @Test + void reloadRefreshesConfigAndKeepList() { + assertTrue(run(console, "RELOAD")); + + verify(plugin).reloadConfig(); + verify(plugin).reloadKeepList(); + assertEquals(List.of(GREEN + "PermCleaner reloaded season " + WHITE + "vardera" + + GREEN + " world " + WHITE + "lobby"), messages(console)); + } + + @Test + void statusForSelfInMatchingWorld() { + when(stamps.getSeason(ALEX)).thenReturn(Optional.of("old")); + when(cleaner.worldMatches(world)).thenReturn(true); + + run(alex, "status"); + + assertEquals(List.of( + GREEN + "Player " + WHITE + "Alex", + GREEN + "Config season " + WHITE + "vardera", + GREEN + "Stored season " + WHITE + "old", + GREEN + "Needs clean " + WHITE + "yes", + GREEN + "World " + WHITE + "lobby" + GREEN + " gate " + WHITE + "pass"), messages(alex)); + } + + @Test + void statusForOnlinePlayerInOtherWorld() { + when(stamps.getSeason(BEA)).thenReturn(Optional.of("vardera")); + when(stamps.needsClean(BEA, "vardera")).thenReturn(false); + when(cleaner.worldMatches(world)).thenReturn(false); + + run(console, "status", "Bea"); + + assertEquals(List.of( + GREEN + "Player " + WHITE + "Bea", + GREEN + "Config season " + WHITE + "vardera", + GREEN + "Stored season " + WHITE + "vardera", + GREEN + "Needs clean " + WHITE + "no", + GREEN + "World " + WHITE + "lobby" + GREEN + " gate " + WHITE + "fail"), messages(console)); + } + + @Test + void statusForOfflinePlayerWithoutStampStore() { + offline("Cai", "Cai"); + when(plugin.stamps()).thenReturn(null); + + run(console, "status", "Cai"); + + assertEquals(List.of( + GREEN + "Player " + WHITE + "Cai", + GREEN + "Config season " + WHITE + "vardera", + GREEN + "Stored season " + WHITE + "(none)", + GREEN + "Needs clean " + WHITE + "no", + GREEN + "World " + WHITE + "lobby" + GREEN + " gate " + WHITE + "offline"), messages(console)); + verify(cleaner, never()).worldMatches(any()); + } + + @Test + void consoleMustNamePlayer() { + run(console, "status"); + assertEquals(List.of(RED + "Console must specify a player."), messages(console)); + } + + @Test + void unknownPlayerIsRejected() { + run(console, "status", "Nobody"); + assertEquals(List.of(RED + "Unknown player Nobody"), messages(console)); + } + + @Test + void offlinePlayerWithoutUuidIsRejected() { + OfflinePlayer offline = mock(OfflinePlayer.class); + when(offline.hasPlayedBefore()).thenReturn(true); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("Ghost")).thenReturn(offline); + + run(console, "status", "Ghost"); + + assertEquals(List.of(RED + "Unknown player Ghost"), messages(console)); + } + + @Test + void offlinePlayerWithoutNameUsesArgument() { + UUID uuid = offline("dee", null); + + run(console, "clean", "dee"); + + verify(cleaner).startClean(eq(uuid), eq("dee"), any()); + } + + @Test + void inspectRejectsUnknownTarget() { + run(console, "inspect"); + verify(cleaner, never()).inspect(any(), any()); + } + + @Test + void inspectListsNodesUpToCap() { + List nodes = new ArrayList<>(); + for (int i = 0; i < 41; i++) { + nodes.add(new NodeReport("node." + i, "PermissionNode", "{}", i % 2 == 0)); + } + + run(console, "inspect", "Bea"); + inspectCallback(BEA).accept(new CleanResult(21, 20, nodes)); + + List messages = messages(console); + assertEquals(43, messages.size()); + assertEquals(GREEN + "Inspecting " + WHITE + "Bea" + GREEN + "...", messages.get(0)); + assertEquals(GREEN + "Inspect " + WHITE + "Bea" + GREEN + " remove " + WHITE + 21 + + GREEN + " keep " + WHITE + 20, messages.get(1)); + assertEquals(RED + "remove " + WHITE + "node.0" + GRAY + " {}", messages.get(2)); + assertEquals(GREEN + "keep " + WHITE + "node.1" + GRAY + " {}", messages.get(3)); + assertEquals(RED + "remove " + WHITE + "node.38" + GRAY + " {}", messages.get(40)); + assertEquals(GREEN + "keep " + WHITE + "node.39" + GRAY + " {}", messages.get(41)); + assertEquals(GRAY + "... and 1 more", messages.get(42)); + } + + @Test + void inspectListsAllNodesUnderCap() { + run(alex, "inspect"); + inspectCallback(ALEX).accept(new CleanResult(0, 1, + List.of(new NodeReport("group.default", "InheritanceNode", "{}", false)))); + + assertEquals(List.of( + GREEN + "Inspecting " + WHITE + "Alex" + GREEN + "...", + GREEN + "Inspect " + WHITE + "Alex" + GREEN + " remove " + WHITE + 0 + + GREEN + " keep " + WHITE + 1, + GREEN + "keep " + WHITE + "group.default" + GRAY + " {}"), messages(alex)); + } + + @Test + void inspectReportsFailure() { + CommandSender sender = mock(CommandSender.class); + run(sender, "inspect", "Bea"); + inspectCallback(BEA).accept(null); + + assertEquals(List.of( + GREEN + "Inspecting " + WHITE + "Bea" + GREEN + "...", + RED + "Inspect failed for Bea"), messages(sender)); + } + + @Test + void inspectSkipsPlayersWhoLeft() { + run(alex, "inspect"); + when(alex.isOnline()).thenReturn(false); + inspectCallback(ALEX).accept(null); + + assertEquals(List.of(GREEN + "Inspecting " + WHITE + "Alex" + GREEN + "..."), messages(alex)); + } + + @Test + void cleanSkipsStampedPlayer() { + when(stamps.needsClean(BEA, "vardera")).thenReturn(false); + + run(console, "clean", "Bea"); + + verify(cleaner, never()).startClean(any(), anyString(), any()); + assertEquals(List.of(YELLOW + "Bea already stamped for vardera"), messages(console)); + } + + @Test + void cleanReportsRemovedCount() { + run(console, "clean", "Bea"); + cleanCallback(BEA).accept(new CleanResult(3, 1, List.of())); + + assertEquals(List.of(GREEN + "Cleaned " + WHITE + "Bea" + GREEN + " removed " + WHITE + 3), + messages(console)); + } + + @Test + void cleanWithoutStampStoreStillStarts() { + when(plugin.stamps()).thenReturn(null); + + run(alex, "clean"); + cleanCallback(ALEX).accept(new CleanResult(0, 0, List.of())); + + assertEquals(List.of(GREEN + "Cleaned " + WHITE + "Alex" + GREEN + " removed " + WHITE + 0), + messages(alex)); + } + + @Test + void forceIgnoresStamp() { + when(stamps.needsClean(BEA, "vardera")).thenReturn(false); + + run(console, "force", "Bea"); + cleanCallback(BEA).accept(new CleanResult(2, 0, List.of())); + + verify(stamps, never()).needsClean(any(), anyString()); + assertEquals(List.of(GREEN + "Forced " + WHITE + "Bea" + GREEN + " removed " + WHITE + 2), + messages(console)); + } + + @Test + void cleanReportsFailure() { + CommandSender sender = mock(CommandSender.class); + run(sender, "clean", "Bea"); + cleanCallback(BEA).accept(null); + + assertEquals(List.of(RED + "Clean failed for Bea"), messages(sender)); + } + + @Test + void cleanSkipsPlayersWhoLeft() { + run(alex, "force"); + when(alex.isOnline()).thenReturn(false); + cleanCallback(ALEX).accept(new CleanResult(1, 0, List.of())); + + assertTrue(messages(alex).isEmpty()); + } + + @Test + void cleanReportsRunningClean() { + when(cleaner.startClean(any(), anyString(), any())).thenReturn(false); + + run(console, "clean", "Bea"); + + assertEquals(List.of(YELLOW + "Clean already running for Bea"), messages(console)); + } + + @Test + void cleanRejectsUnknownTarget() { + run(console, "force", "Nobody"); + verify(cleaner, never()).startClean(any(), anyString(), any()); + } + + @Test + void tabCompletesSubcommands() { + assertEquals(List.of("reload", "status", "inspect", "clean", "force"), tab("")); + assertEquals(List.of("status"), tab("ST")); + assertEquals(List.of("reload", "status", "inspect", "clean", "force"), tab((String) null)); + } + + @Test + void tabCompletesPlayersForTargetedSubcommands() { + assertEquals(List.of("Alex", "Bea"), tab("status", "")); + assertEquals(List.of("Bea"), tab("Inspect", "b")); + assertEquals(List.of("Alex"), tab("clean", "a")); + assertEquals(List.of("Alex", "Bea"), tab("force", "")); + } + + @Test + void tabCompletesNothingElse() { + assertEquals(List.of(), tab("reload", "")); + assertEquals(List.of(), tab("status", "Bea", "")); + assertEquals(List.of(), tab()); + } + + @Test + void blankSeasonRejectsCleanAndForceWithAnActionableMessage() { + when(plugin.seasonId()).thenReturn(" "); + run(console, "clean", "Bea"); + run(console, "force", "Bea"); + verify(cleaner, never()).startClean(any(), anyString(), any()); + assertEquals(List.of(RED + "Cleaning is disabled: season-id is blank.", + RED + "Cleaning is disabled: season-id is blank."), messages(console)); + } + + @Test + void cachedOfflineLookupNeverCallsTheBlockingNameResolver() { + OfflinePlayer cached = mock(OfflinePlayer.class); + when(cached.getUniqueId()).thenReturn(ALEX); + when(cached.hasPlayedBefore()).thenReturn(true); + when(cached.getName()).thenReturn("Alex"); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("Alex")).thenReturn(cached); + run(console, "status", "Alex"); + bukkit.verify(() -> Bukkit.getOfflinePlayer("Alex"), never()); + assertEquals(GREEN + "Player " + WHITE + "Alex", messages(console).get(0)); + } + + @Test + void uncachedPlayerResolvesAsynchronouslyAndContinuesOnlyOnMainThread() { + OfflinePlayer offline = uncached("Cai", ALEX); + run(console, "clean", "Cai"); + assertTrue(messages(console).isEmpty()); + bukkit.verify(() -> Bukkit.getPlayerUniqueId("Cai"), never()); + verify(offline, never()).hasPlayedBefore(); + verify(cleaner, never()).startClean(any(), anyString(), any()); + asyncTasks.remove().run(); + verify(cleaner, never()).startClean(any(), anyString(), any()); + verify(offline, never()).hasPlayedBefore(); + assertEquals(1, mainTasks.size()); + drainMain(); + verify(cleaner).startClean(eq(ALEX), eq("Cai"), any()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + + @Test + void incompleteOrFailedLookupDoesNotCleanAnotherIdentity() { + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(anyString())).thenReturn(null); + run(console, "force", "Missing"); + asyncTasks.remove().run(); drainMain(); + bukkit.when(() -> Bukkit.getPlayerUniqueId("Failed")).thenThrow(new IllegalStateException("network offline")); + run(console, "inspect", "Failed"); + asyncTasks.remove().run(); drainMain(); + assertEquals(List.of(RED + "Unknown player Missing", RED + "Player lookup failed for Failed. Please try again."), messages(console)); + verify(cleaner, never()).startClean(any(), anyString(), any()); + verify(cleaner, never()).inspect(any(), any()); + } + + @Test + void offlineModeUsesTheServersOfflineUuid() { + bukkit.when(Bukkit::getOnlineMode).thenReturn(false); + UUID uuid = UUID.nameUUIDFromBytes("OfflinePlayer:Cai".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + uncached("Cai", uuid); + run(console, "inspect", "Cai"); + asyncTasks.remove().run(); drainMain(); + verify(cleaner).inspect(eq(uuid), any()); + bukkit.verify(() -> Bukkit.getPlayerUniqueId("Cai")); + } + + @Test + void blankNameIsReportedWithoutThrowingOrScheduling() { + run(console, "status", " "); + assertEquals(List.of(RED + "Unknown player "), messages(console)); + assertTrue(asyncTasks.isEmpty()); + } + + @Test + void completedLookupIsDroppedAfterDisableOrSenderDisconnect() { + uncached("Cai", ALEX); + run(alex, "clean", "Cai"); + when(alex.isOnline()).thenReturn(false); + asyncTasks.remove().run(); drainMain(); + assertTrue(messages(alex).isEmpty()); + run(console, "clean", "Cai"); + when(plugin.isEnabled()).thenReturn(false); + asyncTasks.remove().run(); + assertTrue(mainTasks.isEmpty()); + verify(cleaner, never()).startClean(any(), anyString(), any()); + } + + @Test + void disableBetweenLookupAndMainCallbackCancelsContinuation() { + uncached("Cai", ALEX); + run(console, "clean", "Cai"); + asyncTasks.remove().run(); + when(plugin.isEnabled()).thenReturn(false); + drainMain(); + assertTrue(messages(console).isEmpty()); + } + + @Test + void schedulerDisableRaceDropsLookupResult() { + uncached("Cai", ALEX); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenThrow(new IllegalPluginAccessException("disabled")); + run(console, "clean", "Cai"); + asyncTasks.remove().run(); + assertTrue(messages(console).isEmpty()); + verify(cleaner, never()).startClean(any(), anyString(), any()); + } + + @Test + void disabledSchedulerDoesNotSubmitNameLookup() { + uncached("Cai", ALEX); + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenThrow(new IllegalPluginAccessException("disabled")); + run(console, "clean", "Cai"); + assertTrue(asyncTasks.isEmpty()); + bukkit.verify(() -> Bukkit.getPlayerUniqueId(anyString()), never()); + } + + @Test + void uncachedProxyPlayerUsesServerIdentityWhenBackendOnlineModeIsFalse() { + bukkit.when(Bukkit::getOnlineMode).thenReturn(false); + uncached("Cai", ALEX); + run(console, "clean", "Cai"); + assertEquals(1, asyncTasks.size(), "Identity lookup must defer to Paper's proxy-aware resolver"); + verify(cleaner, never()).startClean(any(), anyString(), any()); + asyncTasks.remove().run(); drainMain(); + verify(cleaner).startClean(eq(ALEX), eq("Cai"), any()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(ALEX)); + } + + private OfflinePlayer uncached(String name, UUID uuid) { + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(name)).thenReturn(null); + bukkit.when(() -> Bukkit.getPlayerUniqueId(name)).thenReturn(uuid); + OfflinePlayer offline = mock(OfflinePlayer.class); + when(offline.hasPlayedBefore()).thenReturn(true); + when(offline.getUniqueId()).thenReturn(uuid); + when(offline.getName()).thenReturn(name); + bukkit.when(() -> Bukkit.getOfflinePlayer(uuid)).thenReturn(offline); + return offline; + } + + private void drainMain() { + while (!mainTasks.isEmpty()) mainTasks.remove().run(); + } + + private boolean run(CommandSender sender, String... args) { + boolean handled = executor.onCommand(sender, command, "permcleaner", args); + drainMain(); + return handled; + } + + private List tab(String... args) { + return executor.onTabComplete(console, command, "permcleaner", args); + } + + @SuppressWarnings("unchecked") + private Consumer inspectCallback(UUID uuid) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class); + verify(cleaner).inspect(eq(uuid), captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private Consumer cleanCallback(UUID uuid) { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class); + verify(cleaner).startClean(eq(uuid), anyString(), captor.capture()); + return captor.getValue(); + } + + private UUID offline(String arg, String name) { + UUID uuid = UUID.nameUUIDFromBytes(arg.getBytes()); + OfflinePlayer offline = mock(OfflinePlayer.class); + when(offline.hasPlayedBefore()).thenReturn(true); + when(offline.getUniqueId()).thenReturn(uuid); + when(offline.getName()).thenReturn(name); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached(arg)).thenReturn(offline); + return uuid; + } + + private Player player(UUID uuid, String name) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getName()).thenReturn(name); + when(player.getWorld()).thenReturn(world); + when(player.isOnline()).thenReturn(true); + return player; + } + + private static List messages(CommandSender sender) { + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(sender, atLeast(0)).sendMessage(captor.capture()); + return captor.getAllValues(); + } +} diff --git a/src/test/java/net/tfminecraft/permcleaner/keep/KeepListTest.java b/src/test/java/net/tfminecraft/permcleaner/keep/KeepListTest.java index 1f3ceb6..8d0779f 100644 --- a/src/test/java/net/tfminecraft/permcleaner/keep/KeepListTest.java +++ b/src/test/java/net/tfminecraft/permcleaner/keep/KeepListTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.BeforeAll; @@ -71,4 +72,32 @@ void blankAndNullAreNotKept() { assertFalse(keep.keeps(null)); assertFalse(keep.keeps(" ")); } + + @Test + void productionListIsNotEmpty() { + assertFalse(keep.isEmpty()); + } + + @Test + void nullPatternsKeepNothing() { + KeepList empty = KeepList.fromPatterns(null); + assertTrue(empty.isEmpty()); + assertFalse(empty.keeps("group.default")); + } + + @Test + void skipsBlankAndMalformedPatterns() { + KeepList skipped = KeepList.fromPatterns(Arrays.asList(null, " ", ".*", " .* ", "a*.*", "a*b", "*")); + assertTrue(skipped.isEmpty()); + } + + @Test + void trimsPatternsAndMatchesPrefixCaseInsensitive() { + KeepList trimmed = KeepList.fromPatterns(List.of(" Rpchar.* ", " Tfmc.Staff ")); + assertTrue(trimmed.keeps("rpchar")); + assertTrue(trimmed.keeps(" RPCHAR.Group.Noble ")); + assertTrue(trimmed.keeps("tfmc.staff")); + assertFalse(trimmed.keeps("rpcharacters")); + assertFalse(trimmed.keeps("tfmc.staff.extra")); + } } diff --git a/src/test/java/net/tfminecraft/permcleaner/listener/SeasonCleanListenerTest.java b/src/test/java/net/tfminecraft/permcleaner/listener/SeasonCleanListenerTest.java new file mode 100644 index 0000000..3870518 --- /dev/null +++ b/src/test/java/net/tfminecraft/permcleaner/listener/SeasonCleanListenerTest.java @@ -0,0 +1,51 @@ +package net.tfminecraft.permcleaner.listener; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.permcleaner.lp.SeasonCleanService; + +class SeasonCleanListenerTest { + + private SeasonCleanService service; + private Player player; + private World world; + private SeasonCleanListener listener; + + @BeforeEach + void setUp() { + service = mock(SeasonCleanService.class); + player = mock(Player.class); + world = mock(World.class); + when(player.getWorld()).thenReturn(world); + listener = new SeasonCleanListener(service); + } + + @Test + void joinConsidersPlayerInCurrentWorld() { + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + + listener.onJoin(event); + + verify(service).consider(player, world); + } + + @Test + void worldChangeConsidersPlayerInNewWorld() { + PlayerChangedWorldEvent event = mock(PlayerChangedWorldEvent.class); + when(event.getPlayer()).thenReturn(player); + + listener.onWorldChange(event); + + verify(service).consider(player, world); + } +} diff --git a/src/test/java/net/tfminecraft/permcleaner/lp/CleanResultTest.java b/src/test/java/net/tfminecraft/permcleaner/lp/CleanResultTest.java new file mode 100644 index 0000000..9c478f5 --- /dev/null +++ b/src/test/java/net/tfminecraft/permcleaner/lp/CleanResultTest.java @@ -0,0 +1,58 @@ +package net.tfminecraft.permcleaner.lp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.permcleaner.lp.CleanResult.NodeReport; + +class CleanResultTest { + + @Test + void unchangedWhenNothingRemoved() { + CleanResult result = new CleanResult(0, 3, List.of()); + assertEquals(0, result.removed()); + assertEquals(3, result.kept()); + assertFalse(result.changed()); + } + + @Test + void changedWhenNodesRemoved() { + assertTrue(new CleanResult(2, 0, List.of()).changed()); + } + + @Test + void copiesNodeReports() { + List source = new ArrayList<>(); + source.add(new NodeReport("professions.foo", "PermissionNode", "{}", true)); + CleanResult result = new CleanResult(1, 0, source); + source.clear(); + + assertEquals(1, result.nodes().size()); + assertThrows(UnsupportedOperationException.class, () -> result.nodes().clear()); + } + + @Test + void nodeReportKeepsValues() { + NodeReport report = new NodeReport("group.default", "InheritanceNode", "{server=lobby}", false); + assertEquals("group.default", report.key()); + assertEquals("InheritanceNode", report.type()); + assertEquals("{server=lobby}", report.context()); + assertFalse(report.remove()); + } + + @Test + void nodeReportReplacesNullsWithBlanks() { + NodeReport report = new NodeReport(null, null, null, true); + assertEquals("", report.key()); + assertEquals("", report.type()); + assertEquals("", report.context()); + assertTrue(report.remove()); + } +} diff --git a/src/test/java/net/tfminecraft/permcleaner/lp/SeasonCleanServiceTest.java b/src/test/java/net/tfminecraft/permcleaner/lp/SeasonCleanServiceTest.java new file mode 100644 index 0000000..d99054c --- /dev/null +++ b/src/test/java/net/tfminecraft/permcleaner/lp/SeasonCleanServiceTest.java @@ -0,0 +1,621 @@ +package net.tfminecraft.permcleaner.lp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.context.ImmutableContextSet; +import net.luckperms.api.model.data.NodeMap; +import net.luckperms.api.model.user.User; +import net.luckperms.api.model.user.UserManager; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.types.PermissionNode; +import net.tfminecraft.permcleaner.PermCleaner; +import net.tfminecraft.permcleaner.keep.KeepList; +import net.tfminecraft.permcleaner.store.SeasonStampStore; + +class SeasonCleanServiceTest { + + private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + + private final Deque asyncTasks = new ArrayDeque<>(); + private final Deque mainTasks = new ArrayDeque<>(); + private final List results = new ArrayList<>(); + private final Consumer callback = results::add; + + private PermCleaner plugin; + private SeasonStampStore stamps; + private Logger logger; + private UserManager users; + private NodeMap data; + private User user; + private MockedStatic bukkit; + private MockedStatic luckPerms; + private SeasonCleanService service; + private BukkitScheduler scheduler; + + @BeforeEach + void setUp() { + plugin = mock(PermCleaner.class); + stamps = mock(SeasonStampStore.class); + logger = mock(Logger.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.seasonId()).thenReturn("vardera"); + when(plugin.worldName()).thenReturn(""); + when(plugin.stamps()).thenReturn(stamps); + when(plugin.keepList()).thenReturn(KeepList.fromPatterns(List.of("group.*"))); + when(plugin.getLogger()).thenReturn(logger); + when(stamps.needsClean(PLAYER, "vardera")).thenReturn(true); + + scheduler = mock(BukkitScheduler.class); + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + asyncTasks.add(invocation.getArgument(1)); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + mainTasks.add(invocation.getArgument(1)); + return null; + }); + bukkit = Mockito.mockStatic(Bukkit.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + // Callbacks are only ever raised from async tasks, so they hop back through runTask. + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + + data = mock(NodeMap.class); + user = mock(User.class); + when(user.data()).thenReturn(data); + givenNodes(permission("professions.vegetables")); + users = mock(UserManager.class); + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(user)); + when(users.saveUser(user)).thenReturn(CompletableFuture.completedFuture(null)); + LuckPerms api = mock(LuckPerms.class); + when(api.getUserManager()).thenReturn(users); + luckPerms = Mockito.mockStatic(LuckPermsProvider.class); + luckPerms.when(LuckPermsProvider::get).thenReturn(api); + + service = new SeasonCleanService(plugin); + } + + @AfterEach + void tearDown() { + luckPerms.close(); + bukkit.close(); + } + + @Test + void considerIgnoresMissingPlayer() { + service.consider(null, null); + assertNoTasks(); + } + + @Test + void considerIgnoresDisabledPlugin() { + when(plugin.isEnabled()).thenReturn(false); + service.consider(player(), null); + assertNoTasks(); + } + + @Test + void considerIgnoresOtherWorlds() { + when(plugin.worldName()).thenReturn("lobby"); + service.consider(player(), world("survival")); + assertNoTasks(); + } + + @Test + void considerIgnoresMissingStampStore() { + when(plugin.stamps()).thenReturn(null); + service.consider(player(), null); + assertNoTasks(); + } + + @Test + void considerIgnoresStampedPlayers() { + when(stamps.needsClean(PLAYER, "vardera")).thenReturn(false); + service.consider(player(), null); + assertNoTasks(); + } + + @Test + void considerCleansUnstampedPlayers() { + when(plugin.worldName()).thenReturn("lobby"); + service.consider(player(), world("Lobby")); + runAll(); + + verify(data).remove(any(Node.class)); + verify(users).saveUser(user); + verify(stamps).setSeason(PLAYER, "vardera"); + verify(logger).info("Alex season vardera removed 1"); + } + + @Test + void worldMatchesAnyWorldWhenUnset() { + assertTrue(service.worldMatches(null)); + when(plugin.worldName()).thenReturn(null); + assertTrue(service.worldMatches(world("survival"))); + } + + @Test + void worldMatchesConfiguredWorldIgnoringCase() { + when(plugin.worldName()).thenReturn("lobby"); + assertTrue(service.worldMatches(world("LOBBY"))); + assertFalse(service.worldMatches(world("survival"))); + assertFalse(service.worldMatches(null)); + } + + @Test + void startCleanRejectsMissingUuid() { + assertFalse(service.startClean(null, "Alex", callback)); + assertNoTasks(); + } + + @Test + void startCleanRejectsDisabledPlugin() { + when(plugin.isEnabled()).thenReturn(false); + assertFalse(service.startClean(PLAYER, "Alex", callback)); + assertNoTasks(); + } + + @Test + void startCleanRejectsDuplicateUntilFinished() { + assertTrue(service.startClean(PLAYER, "Alex", callback)); + assertFalse(service.startClean(PLAYER, "Alex", callback)); + runAll(); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void cleanSavesStampsAndReports() { + assertTrue(service.startClean(PLAYER, "Alex", callback)); + runAll(); + + verify(users).saveUser(user); + verify(stamps).setSeason(PLAYER, "vardera"); + assertEquals(1, results.size()); + assertEquals(1, results.get(0).removed()); + } + + @Test + void cleanPersistsEvenWhenNothingRemoved() { + givenNodes(); + service.startClean(PLAYER, "Alex", callback); + runAll(); + + verify(users).saveUser(user); + verify(stamps).setSeason(PLAYER, "vardera"); + assertFalse(results.get(0).changed()); + } + + @Test + void cleanUsesSeasonCapturedAtStart() { + service.startClean(PLAYER, "Alex", callback); + when(plugin.seasonId()).thenReturn("next"); + runAll(); + + verify(stamps).setSeason(PLAYER, "vardera"); + } + + @Test + void cleanWithoutCallbackStillStamps() { + service.startClean(PLAYER, "Alex", null); + runAll(); + + verify(stamps).setSeason(PLAYER, "vardera"); + assertTrue(service.startClean(PLAYER, "Alex", null)); + } + + @Test + void cleanReportsFailureWhenDisabledAfterLoad() { + // Enabled to start, disabled once the async clean finishes, then enabled for the callback. + when(plugin.isEnabled()).thenReturn(true, false, true); + service.startClean(PLAYER, "Alex", callback); + runAll(); + + verify(stamps, never()).setSeason(any(), anyString()); + assertEquals(1, results.size()); + assertNull(results.get(0)); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void cleanWithoutCallbackToleratesDisableAfterLoad() { + when(plugin.isEnabled()).thenReturn(true, false, true); + service.startClean(PLAYER, "Alex", null); + runAll(); + + verify(stamps, never()).setSeason(any(), anyString()); + } + + @Test + void cleanDropsCallbackWhenPluginStaysDisabled() { + when(plugin.isEnabled()).thenReturn(true, false); + service.startClean(PLAYER, "Alex", callback); + runAll(); + + assertTrue(results.isEmpty()); + } + + @Test + void cleanReportsFailureWhenUserMissing() { + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(null)); + service.startClean(PLAYER, "Alex", callback); + runAll(); + + verify(logger).log(eq(Level.WARNING), eq("Failed to clean permissions for Alex"), any(IllegalStateException.class)); + assertEquals(1, results.size()); + assertNull(results.get(0)); + verify(stamps, never()).setSeason(any(), anyString()); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void cleanFailureWithoutCallbackIsLogged() { + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(null)); + service.startClean(PLAYER, "Alex", null); + runAll(); + + verify(logger).log(eq(Level.WARNING), eq("Failed to clean permissions for Alex"), any(IllegalStateException.class)); + } + + @Test + void failureCallbackIsScheduledOnMainThread() { + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(null)); + service.startClean(PLAYER, "Alex", callback); + runAsync(); + + assertTrue(results.isEmpty()); + assertEquals(1, mainTasks.size()); + runAll(); + assertEquals(1, results.size()); + } + + @Test + void failureCallbackRunsDirectlyWhenAlreadyOnMainThread() { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(null)); + service.startClean(PLAYER, "Alex", callback); + runAsync(); + + assertTrue(mainTasks.isEmpty()); + assertEquals(1, results.size()); + } + + @Test + void stampSkippedWhenDisabledBeforeMainThread() { + when(plugin.isEnabled()).thenReturn(true, true, false); + service.startClean(PLAYER, "Alex", callback); + runAll(); + + verify(stamps, never()).setSeason(any(), anyString()); + verify(logger, never()).info(anyString()); + assertEquals(1, results.get(0).removed()); + } + + @Test + void stampSkippedWhenStoreClosedBeforeMainThread() { + service.startClean(PLAYER, "Alex", callback); + runAsync(); + when(plugin.stamps()).thenReturn(null); + runAll(); + + verify(stamps, never()).setSeason(any(), anyString()); + assertEquals(1, results.get(0).removed()); + } + + @Test + void stampFailureIsReported() { + doThrow(new IllegalStateException("closed")).when(stamps).setSeason(PLAYER, "vardera"); + service.startClean(PLAYER, "Alex", callback); + runAll(); + + verify(logger).log(eq(Level.WARNING), eq("Failed to stamp season for Alex"), any(IllegalStateException.class)); + assertEquals(1, results.size()); + assertNull(results.get(0)); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void stampFailureWithoutCallbackIsLogged() { + doThrow(new IllegalStateException("closed")).when(stamps).setSeason(PLAYER, "vardera"); + service.startClean(PLAYER, "Alex", null); + runAll(); + + verify(logger).log(eq(Level.WARNING), eq("Failed to stamp season for Alex"), any(IllegalStateException.class)); + assertTrue(service.startClean(PLAYER, "Alex", null)); + } + + @Test + void inspectReportsWithoutChangingUser() { + service.inspect(PLAYER, callback); + runAll(); + + assertEquals(1, results.size()); + assertEquals(1, results.get(0).removed()); + verify(data, never()).remove(any(Node.class)); + verify(users, never()).saveUser(any()); + verifyNoInteractions(stamps); + } + + @Test + void inspectWithoutCallback() { + service.inspect(PLAYER, null); + runAll(); + + verify(users).loadUser(PLAYER); + } + + @Test + void inspectReportsFailureWhenUserMissing() { + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(null)); + service.inspect(PLAYER, callback); + runAll(); + + verify(logger).log(eq(Level.WARNING), eq("Failed to inspect permissions for " + PLAYER), any(IllegalStateException.class)); + assertEquals(1, results.size()); + assertNull(results.get(0)); + } + + @Test + void inspectFailureWithoutCallbackIsLogged() { + when(users.loadUser(PLAYER)).thenReturn(CompletableFuture.completedFuture(null)); + service.inspect(PLAYER, null); + runAll(); + + verify(logger).log(eq(Level.WARNING), eq("Failed to inspect permissions for " + PLAYER), any(IllegalStateException.class)); + } + + @Test + void inspectCallbackIsScheduledOnMainThread() { + service.inspect(PLAYER, callback); + runAsync(); + + assertTrue(results.isEmpty()); + runAll(); + assertEquals(1, results.size()); + } + + @Test + void blankSeasonCannotCleanOrReserveAPlayer() { + when(plugin.seasonId()).thenReturn(" "); + service.consider(player(), null); + assertFalse(service.startClean(PLAYER, "Alex", callback)); + assertNoTasks(); + verifyNoInteractions(users, stamps); + when(plugin.seasonId()).thenReturn("vardera"); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void rejectedAsyncSubmissionReleasesReservationForRetry() { + RuntimeException rejected = new IllegalStateException("scheduler rejected"); + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))) + .thenThrow(rejected).thenAnswer(invocation -> { asyncTasks.add(invocation.getArgument(1)); return null; }); + assertSame(rejected, assertThrows(IllegalStateException.class, + () -> service.startClean(PLAYER, "Alex", callback))); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + runAll(); + assertEquals(1, results.size()); + verify(stamps).setSeason(PLAYER, "vardera"); + } + + @Test + void failedWorkerReleasesReservationEvenForAnError() { + luckPerms.when(LuckPermsProvider::get).thenThrow(new NoClassDefFoundError("provider unloaded")); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + assertThrows(NoClassDefFoundError.class, this::runAsync); + assertEquals(1, mainTasks.size()); + mainTasks.remove().run(); + assertEquals(1, results.size()); + assertNull(results.get(0)); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + verifyNoInteractions(stamps); + } + + @Test + void throwingCallbackIsInvokedOnceAndReleasesReservation() { + int[] calls = {0}; + IllegalStateException failure = new IllegalStateException("consumer failed"); + assertTrue(service.startClean(PLAYER, "Alex", result -> { calls[0]++; throw failure; })); + assertSame(failure, assertThrows(IllegalStateException.class, this::runAll)); + assertEquals(1, calls[0]); + verify(stamps).setSeason(PLAYER, "vardera"); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void inspectThrowingCallbackIsNotReportedAgainAsInspectionFailure() { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + int[] calls = {0}; + IllegalStateException failure = new IllegalStateException("consumer failed"); + service.inspect(PLAYER, result -> { calls[0]++; throw failure; }); + assertSame(failure, assertThrows(IllegalStateException.class, this::runAll)); + assertEquals(1, calls[0]); + verify(users).loadUser(PLAYER); + } + + @Test + void rejectedMainSubmissionReleasesReservationForRetry() { + when(scheduler.runTask(eq(plugin), any(Runnable.class))) + .thenThrow(new IllegalStateException("scheduler rejected")); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + assertThrows(IllegalStateException.class, this::runAsync); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + verifyNoInteractions(stamps); + } + + @Test + void retryAfterFailedSaveStillPersistsTheMutatedUserBeforeStamping() { + when(users.saveUser(user)).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("storage offline")), + CompletableFuture.completedFuture(null)); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + runAll(); + assertNull(results.get(0)); + verify(stamps, never()).setSeason(any(), anyString()); + // LuckPerms keeps the mutated user loaded even though persistence failed. + givenNodes(); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + runAll(); + verify(users, org.mockito.Mockito.times(2)).saveUser(user); + verify(stamps).setSeason(PLAYER, "vardera"); + } + + @Test + void errorReportsFailureOnceEvenIfFailureCallbackThrows() { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + NoClassDefFoundError original = new NoClassDefFoundError("provider unloaded"); + IllegalStateException secondary = new IllegalStateException("consumer failed"); + luckPerms.when(LuckPermsProvider::get).thenThrow(original); + int[] calls = {0}; + assertTrue(service.startClean(PLAYER, "Alex", result -> { assertNull(result); calls[0]++; throw secondary; })); + assertSame(original, assertThrows(NoClassDefFoundError.class, this::runAsync)); + assertEquals(1, calls[0]); + assertEquals(List.of(secondary), List.of(original.getSuppressed())); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void failureNotificationRejectionCannotMaskTheOriginalWorkerError() { + NoClassDefFoundError original = new NoClassDefFoundError("provider unloaded"); + IllegalStateException secondary = new IllegalStateException("scheduler stopped"); + luckPerms.when(LuckPermsProvider::get).thenThrow(original); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenThrow(secondary); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + assertSame(original, assertThrows(NoClassDefFoundError.class, this::runAsync)); + assertEquals(List.of(secondary), List.of(original.getSuppressed())); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void rejectedStampSubmissionNotifiesFailureBeforeAllowingRetry() { + IllegalStateException rejected = new IllegalStateException("stamp task rejected"); + when(scheduler.runTask(eq(plugin), any(Runnable.class))) + .thenThrow(rejected).thenAnswer(invocation -> { mainTasks.add(invocation.getArgument(1)); return null; }); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + assertSame(rejected, assertThrows(IllegalStateException.class, this::runAsync)); + assertEquals(1, mainTasks.size()); + mainTasks.remove().run(); + assertEquals(1, results.size()); + assertNull(results.get(0)); + verifyNoInteractions(stamps); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void rejectedStampSubmissionPreservesOriginalWhenNotificationThrows() { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + IllegalStateException rejected = new IllegalStateException("stamp task rejected"); + IllegalArgumentException consumerFailure = new IllegalArgumentException("consumer failed"); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenThrow(rejected); + assertTrue(service.startClean(PLAYER, "Alex", result -> { assertNull(result); throw consumerFailure; })); + assertSame(rejected, assertThrows(IllegalStateException.class, this::runAsync)); + assertEquals(List.of(consumerFailure), List.of(rejected.getSuppressed())); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void ordinaryWorkerFailureRetainsThrowingNotificationAndAllowsRetry() { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + IllegalStateException original = new IllegalStateException("provider unavailable"); + IllegalArgumentException secondary = new IllegalArgumentException("consumer failed"); + luckPerms.when(LuckPermsProvider::get).thenThrow(original); + int[] calls = {0}; + assertTrue(service.startClean(PLAYER, "Alex", result -> { assertNull(result); calls[0]++; throw secondary; })); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(this::runAsync); + assertEquals(1, calls[0]); + assertEquals(List.of(secondary), List.of(original.getSuppressed())); + verify(logger).log(Level.WARNING, "Failed to clean permissions for Alex", original); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + @Test + void ordinaryWorkerFailureRetainsRejectedNotificationAndAllowsRetry() { + IllegalStateException original = new IllegalStateException("provider unavailable"); + IllegalArgumentException secondary = new IllegalArgumentException("scheduler stopped"); + luckPerms.when(LuckPermsProvider::get).thenThrow(original); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenThrow(secondary); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(this::runAsync); + assertEquals(List.of(secondary), List.of(original.getSuppressed())); + assertTrue(results.isEmpty()); + assertTrue(service.startClean(PLAYER, "Alex", callback)); + } + + private void givenNodes(Node... nodes) { + when(data.toCollection()).thenReturn(List.of(nodes)); + } + + private static PermissionNode permission(String key) { + PermissionNode node = mock(PermissionNode.class); + when(node.getKey()).thenReturn(key); + when(node.getPermission()).thenReturn(key); + when(node.getContexts()).thenReturn(mock(ImmutableContextSet.class)); + return node; + } + + private static Player player() { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(PLAYER); + when(player.getName()).thenReturn("Alex"); + return player; + } + + private static World world(String name) { + World world = mock(World.class); + when(world.getName()).thenReturn(name); + return world; + } + + private void runAsync() { + while (!asyncTasks.isEmpty()) { + asyncTasks.poll().run(); + } + } + + private void runAll() { + while (!asyncTasks.isEmpty() || !mainTasks.isEmpty()) { + runAsync(); + while (!mainTasks.isEmpty()) { + mainTasks.poll().run(); + } + } + } + + private void assertNoTasks() { + assertTrue(asyncTasks.isEmpty()); + assertTrue(mainTasks.isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/permcleaner/lp/UserPermissionCleanerTest.java b/src/test/java/net/tfminecraft/permcleaner/lp/UserPermissionCleanerTest.java index ada1d22..fe14d32 100644 --- a/src/test/java/net/tfminecraft/permcleaner/lp/UserPermissionCleanerTest.java +++ b/src/test/java/net/tfminecraft/permcleaner/lp/UserPermissionCleanerTest.java @@ -1,8 +1,12 @@ package net.tfminecraft.permcleaner.lp; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; @@ -10,10 +14,15 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import net.luckperms.api.context.ImmutableContextSet; +import net.luckperms.api.model.data.NodeMap; +import net.luckperms.api.model.user.User; +import net.luckperms.api.node.Node; import net.luckperms.api.node.types.InheritanceNode; import net.luckperms.api.node.types.MetaNode; import net.luckperms.api.node.types.PermissionNode; import net.tfminecraft.permcleaner.keep.KeepList; +import net.tfminecraft.permcleaner.lp.CleanResult.NodeReport; class UserPermissionCleanerTest { @@ -68,4 +77,103 @@ void removesProfessionPermissionInServerContext() { when(node.getPermission()).thenReturn("professions.vegetables"); assertTrue(UserPermissionCleaner.shouldRemove(node, keep)); } + + @Test + void keepsWhenNodeOrKeepListMissing() { + PermissionNode node = mock(PermissionNode.class); + when(node.getPermission()).thenReturn("professions.vegetables"); + assertFalse(UserPermissionCleaner.shouldRemove(null, keep)); + assertFalse(UserPermissionCleaner.shouldRemove(node, null)); + } + + @Test + void inspectReportsWithoutRemoving() { + PermissionNode profession = permission("professions.vegetables", "{server=lobby}"); + InheritanceNode group = inheritance("group.default"); + NodeMap data = nodes(profession, group); + + CleanResult result = UserPermissionCleaner.inspect(user(data), keep); + + assertEquals(1, result.removed()); + assertEquals(1, result.kept()); + List reports = result.nodes(); + assertEquals(2, reports.size()); + assertEquals("professions.vegetables", reports.get(0).key()); + assertEquals("{server=lobby}", reports.get(0).context()); + assertEquals(profession.getClass().getSimpleName(), reports.get(0).type()); + assertTrue(reports.get(0).remove()); + assertEquals("group.default", reports.get(1).key()); + assertFalse(reports.get(1).remove()); + verify(data, never()).remove(any()); + } + + @Test + void inspectWithoutUserIsEmpty() { + CleanResult result = UserPermissionCleaner.inspect(null, keep); + assertEquals(0, result.removed()); + assertEquals(0, result.kept()); + assertTrue(result.nodes().isEmpty()); + } + + @Test + void applyRemovesOnlyUnkeptPermissions() { + PermissionNode profession = permission("professions.vegetables", "{}"); + PermissionNode armourshop = permission("armourshop.submission.foo", "{}"); + InheritanceNode group = inheritance("group.default"); + NodeMap data = nodes(profession, armourshop, group); + + CleanResult result = UserPermissionCleaner.apply(user(data), keep); + + assertEquals(1, result.removed()); + assertEquals(2, result.kept()); + assertTrue(result.changed()); + verify(data).remove(profession); + verify(data, never()).remove(armourshop); + verify(data, never()).remove(group); + } + + @Test + void applyWithoutUserIsEmpty() { + CleanResult result = UserPermissionCleaner.apply(null, keep); + assertEquals(0, result.removed()); + assertEquals(0, result.kept()); + assertFalse(result.changed()); + } + + @Test + void applyToUserWithoutNodesChangesNothing() { + NodeMap data = nodes(); + CleanResult result = UserPermissionCleaner.apply(user(data), keep); + assertFalse(result.changed()); + verify(data, never()).remove(any()); + } + + private static PermissionNode permission(String key, String context) { + PermissionNode node = mock(PermissionNode.class); + when(node.getKey()).thenReturn(key); + when(node.getPermission()).thenReturn(key); + ImmutableContextSet contexts = mock(ImmutableContextSet.class); + when(contexts.toString()).thenReturn(context); + when(node.getContexts()).thenReturn(contexts); + return node; + } + + private static InheritanceNode inheritance(String key) { + InheritanceNode node = mock(InheritanceNode.class); + when(node.getKey()).thenReturn(key); + when(node.getContexts()).thenReturn(mock(ImmutableContextSet.class)); + return node; + } + + private static NodeMap nodes(Node... nodes) { + NodeMap data = mock(NodeMap.class); + when(data.toCollection()).thenReturn(List.of(nodes)); + return data; + } + + private static User user(NodeMap data) { + User user = mock(User.class); + when(user.data()).thenReturn(data); + return user; + } } diff --git a/src/test/java/net/tfminecraft/permcleaner/store/SeasonStampStoreTest.java b/src/test/java/net/tfminecraft/permcleaner/store/SeasonStampStoreTest.java index 85d4003..4c2fa07 100644 --- a/src/test/java/net/tfminecraft/permcleaner/store/SeasonStampStoreTest.java +++ b/src/test/java/net/tfminecraft/permcleaner/store/SeasonStampStoreTest.java @@ -2,19 +2,26 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.database.SqliteDatabaseException; import net.tfminecraft.tlibs.database.SqliteProvider; class SeasonStampStoreTest { private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID OTHER = UUID.fromString("22222222-2222-2222-2222-222222222222"); @Test void driverAvailable() { @@ -70,4 +77,83 @@ void blankExpectedAlwaysDirty(@TempDir Path tempDir) { store.close(); } } + + @Test + void nullUuidHasNoSeason(@TempDir Path tempDir) { + SeasonStampStore store = new SeasonStampStore(tempDir.resolve("stamps.db").toFile()); + try { + assertTrue(store.getSeason(null).isEmpty()); + assertTrue(store.needsClean(null, "vardera")); + } finally { + store.close(); + } + } + + @Test + void setSeasonRejectsMissingValues(@TempDir Path tempDir) { + SeasonStampStore store = new SeasonStampStore(tempDir.resolve("stamps.db").toFile()); + try { + assertThrows(IllegalArgumentException.class, () -> store.setSeason(null, "vardera")); + assertThrows(IllegalArgumentException.class, () -> store.setSeason(PLAYER, null)); + assertThrows(IllegalArgumentException.class, () -> store.setSeason(PLAYER, " ")); + assertTrue(store.getSeason(PLAYER).isEmpty()); + } finally { + store.close(); + } + } + + @Test + void seasonSurvivesReopen(@TempDir Path tempDir) { + Path db = tempDir.resolve("stamps.db"); + SeasonStampStore first = new SeasonStampStore(db.toFile()); + first.setSeason(PLAYER, "vardera"); + first.close(); + + SeasonStampStore second = new SeasonStampStore(db.toFile()); + try { + assertEquals("vardera", second.getSeason(PLAYER).orElseThrow()); + } finally { + second.close(); + } + } + + @Test + void nullOrBlankStoredSeasonIsMissing(@TempDir Path tempDir) throws SQLException { + Path db = tempDir.resolve("stamps.db"); + // A table created without the NOT NULL constraint can still hold empty stamps. + sql(db, "CREATE TABLE stamps (uuid TEXT PRIMARY KEY, season_id TEXT, cleaned_at INTEGER)", + "INSERT INTO stamps VALUES ('" + PLAYER + "', NULL, 0)", + "INSERT INTO stamps VALUES ('" + OTHER + "', ' ', 0)"); + SeasonStampStore store = new SeasonStampStore(db.toFile()); + try { + assertTrue(store.getSeason(PLAYER).isEmpty()); + assertTrue(store.getSeason(OTHER).isEmpty()); + assertTrue(store.needsClean(OTHER, "vardera")); + } finally { + store.close(); + } + } + + @Test + void readFailureIsWrapped(@TempDir Path tempDir) throws SQLException { + Path db = tempDir.resolve("stamps.db"); + SeasonStampStore store = new SeasonStampStore(db.toFile()); + try { + sql(db, "DROP TABLE stamps"); + SqliteDatabaseException error = assertThrows(SqliteDatabaseException.class, () -> store.getSeason(PLAYER)); + assertEquals("Failed to read season stamp for " + PLAYER, error.getMessage()); + } finally { + store.close(); + } + } + + private static void sql(Path db, String... statements) throws SQLException { + SqliteProvider.ensureDriverLoaded(); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + db.toAbsolutePath()); + Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } + } }