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);
+ }
+ }
+ }
}