diff --git a/TODO.md b/TODO.md index 7b16dbc..b30af72 100644 --- a/TODO.md +++ b/TODO.md @@ -17,7 +17,7 @@ | MET-013 | Done | Replace Player-keyed storage with UUID-based storage wherever player identity is persisted or tracked. | | MET-014 | Done | Review player lookup logic and use exact or otherwise explicit name-matching behavior where appropriate. | | MET-015 | Done | Refactor remaining code that uses deprecated or removed Bukkit, Spigot, or Paper APIs so it is compatible with the target Paper API. | -| MET-016 | Ready | Add fast unit tests for plot and home allocation, reservation validation, player lookup and UUID-based ownership, configuration persistence, command validation, and event behavior. Keep domain rules independent of a running Minecraft server and mock external plugin boundaries as needed. | +| MET-016 | Done | Add fast unit tests for plot and home allocation, reservation validation, player lookup and UUID-based ownership, configuration persistence, command validation, and event behavior. Keep domain rules independent of a running Minecraft server and mock external plugin boundaries as needed. | | MET-017 | Ready | Add a separate Gradle `integrationTest` source set/task for tests requiring an actual Paper server and compatible WorldGuard/WorldEdit plugins. Verify plugin loading, command registration and dispatch, region-backed reservations and home allocation, and persistence across a server restart. Run it only when explicitly requested; keep it out of the normal `test` and `build` tasks. | | MET-018 | Ready | Build the plugin with Gradle and use the provided server scripts to smoke-test it on modern Paper with its required plugins installed. | | MET-019 | Ready | Address bugs and incompatibilities found by unit tests, integration tests, or modern Paper server testing. | diff --git a/build.gradle b/build.gradle index f48ebef..36d51e1 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,10 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' testImplementation 'org.mockito:mockito-junit-jupiter:5.24.0' + testImplementation 'io.papermc.paper:paper-api:26.2.build.129-stable' + testImplementation 'com.sk89q.worldedit:worldedit-bukkit:7.4.5' + testImplementation 'com.sk89q.worldguard:worldguard-bukkit:7.0.19' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.13.4' } java { diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/CurrentHomesStore.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/CurrentHomesStore.java new file mode 100644 index 0000000..58ad13f --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/CurrentHomesStore.java @@ -0,0 +1,50 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; + +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +final class CurrentHomesStore { + private CurrentHomesStore() {} + + static Map load(File file, Function legacyOwnerResolver) + throws IOException, InvalidConfigurationException { + Map homesByOwner = new HashMap<>(); + if (!file.exists()) { + return homesByOwner; + } + + YamlConfiguration yaml = new YamlConfiguration(); + yaml.load(file); + for (String ownerKey : yaml.getKeys(false)) { + UUID ownerId; + try { + ownerId = UUID.fromString(ownerKey); + } catch (IllegalArgumentException ex) { + ownerId = legacyOwnerResolver.apply(ownerKey); + } + int homeNumber = yaml.getInt(ownerKey, 0); + if (ownerId != null && homeNumber > 0) { + homesByOwner.put(ownerId, homeNumber); + } + } + return homesByOwner; + } + + static void save(File file, Map homesByOwner) throws IOException { + File parent = file.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + + YamlConfiguration yaml = new YamlConfiguration(); + homesByOwner.forEach((ownerId, homeNumber) -> yaml.set(ownerId.toString(), homeNumber)); + yaml.save(file); + } +} \ No newline at end of file diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java index 68d2159..d842a6e 100644 --- a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisPlugin.java @@ -21,8 +21,6 @@ import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.command.PluginCommand; import org.bukkit.configuration.Configuration; -import org.bukkit.configuration.InvalidConfigurationException; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginDescriptionFile; @@ -302,34 +300,13 @@ public class MetropolisPlugin extends JavaPlugin { private void loadCurrentHomes() { File homesFile = new File(getDataFolder(), "currentHomes.yml"); - if (!homesFile.exists()) { - return; - } - YamlConfiguration homes = new YamlConfiguration(); try { - homes.load(homesFile); - } catch (IOException | InvalidConfigurationException e) { - getLogger().log(java.util.logging.Level.SEVERE, "Unable to load currentHomes.yml", e); - return; - } - - _currentHomes.clear(); - boolean migratedLegacyNames = false; - for(String ownerKey : homes.getKeys(false)){ - UUID ownerId; - try { - ownerId = UUID.fromString(ownerKey); - } catch (IllegalArgumentException ex) { - ownerId = getServer().getOfflinePlayer(ownerKey).getUniqueId(); - migratedLegacyNames = true; - } - int homeNumber = homes.getInt(ownerKey, 0); - if (homeNumber > 0) { - _currentHomes.put(ownerId, homeNumber); - } - } - if (migratedLegacyNames) { + _currentHomes.putAll(CurrentHomesStore.load( + homesFile, + ownerName -> getServer().getOfflinePlayer(ownerName).getUniqueId())); saveCurrentHomes(); + } catch (IOException | org.bukkit.configuration.InvalidConfigurationException e) { + getLogger().log(java.util.logging.Level.SEVERE, "Unable to load currentHomes.yml", e); } } @@ -1095,12 +1072,7 @@ public class MetropolisPlugin extends JavaPlugin { if (player != null) { return player; } - for (Player onlinePlayer : getServer().getOnlinePlayers()) { - if (onlinePlayer.getName().equalsIgnoreCase(name)) { - return onlinePlayer; - } - } - return null; + return PlayerLookup.findOnline(name, getServer().getOnlinePlayers()); } public OfflinePlayer getOfflinePlayer(String name){ @@ -1167,11 +1139,8 @@ public class MetropolisPlugin extends JavaPlugin { private void saveCurrentHomes() { File homesFile = new File(getDataFolder(), "currentHomes.yml"); - YamlConfiguration homes = new YamlConfiguration(); - _currentHomes.forEach((playerId, homeNumber) -> homes.set(playerId.toString(), homeNumber)); try { - getDataFolder().mkdirs(); - homes.save(homesFile); + CurrentHomesStore.save(homesFile, _currentHomes); } catch (IOException e) { getLogger().log(java.util.logging.Level.SEVERE, "Unable to save currentHomes.yml", e); } diff --git a/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlayerLookup.java b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlayerLookup.java new file mode 100644 index 0000000..140c7d9 --- /dev/null +++ b/src/main/java/com/majinnaibu/bukkitplugins/metropolis/PlayerLookup.java @@ -0,0 +1,19 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +import org.bukkit.entity.Player; + +final class PlayerLookup { + private PlayerLookup() {} + + static Player findOnline(String requestedName, Iterable onlinePlayers) { + if (requestedName == null) { + return null; + } + for (Player player : onlinePlayers) { + if (requestedName.equalsIgnoreCase(player.getName())) { + return player; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CuboidTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CuboidTest.java new file mode 100644 index 0000000..76d1da0 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CuboidTest.java @@ -0,0 +1,53 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +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 org.bukkit.Location; +import org.junit.jupiter.api.Test; + +import com.sk89q.worldedit.math.BlockVector3; + +class CuboidTest { + @Test + void convertsWorldEditBoundsToCuboidCoordinates() { + Cuboid cuboid = new Cuboid(BlockVector3.at(1, 2, 3), BlockVector3.at(4, 5, 6)); + + assertEquals(1, cuboid.getMinX()); + assertEquals(2, cuboid.getMinY()); + assertEquals(3, cuboid.getMinZ()); + assertEquals(4, cuboid.getMaxX()); + assertEquals(5, cuboid.getMaxY()); + assertEquals(6, cuboid.getMaxZ()); + } + + @Test + void comparesVectorsByXThenZThenYAndHandlesNulls() { + assertEquals(-1, Cuboid.compareBlockVectors(null, BlockVector3.ZERO)); + assertEquals(1, Cuboid.compareBlockVectors(BlockVector3.ZERO, null)); + assertEquals(0, Cuboid.compareBlockVectors(null, null)); + assertTrue(Cuboid.compareBlockVectors(BlockVector3.at(1, 0, 0), BlockVector3.at(2, 0, 0)) < 0); + assertTrue(Cuboid.compareBlockVectors(BlockVector3.at(1, 0, 1), BlockVector3.at(1, 0, 2)) < 0); + assertTrue(Cuboid.compareBlockVectors(BlockVector3.at(1, 1, 2), BlockVector3.at(1, 2, 2)) < 0); + } + + @Test + void treatsTouchingBoundsAsIntersecting() { + Cuboid first = new Cuboid(0, 0, 0, 10, 10, 10); + Cuboid touching = new Cuboid(10, 10, 10, 20, 20, 20); + Cuboid separate = new Cuboid(11, 0, 0, 20, 10, 10); + + assertTrue(first.intersects(touching)); + assertFalse(first.intersects(separate)); + } + + @Test + void containsPointsOnBoundsButRejectsPointsOutside() { + Cuboid cuboid = new Cuboid(1, 2, 3, 4, 5, 6); + + assertTrue(cuboid.contains(new Location(null, 1, 2, 3))); + assertTrue(cuboid.contains(new Location(null, 4, 5, 6))); + assertFalse(cuboid.contains(new Location(null, 5, 5, 6))); + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CurrentHomesStoreTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CurrentHomesStoreTest.java new file mode 100644 index 0000000..001ac5c --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/CurrentHomesStoreTest.java @@ -0,0 +1,41 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CurrentHomesStoreTest { + @TempDir + File temporaryDirectory; + + @Test + void roundTripsHomesByUuid() throws Exception { + File file = new File(temporaryDirectory, "currentHomes.yml"); + UUID playerId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + Map expected = Map.of(playerId, 3); + + CurrentHomesStore.save(file, expected); + + assertEquals(expected, CurrentHomesStore.load(file, ignored -> null)); + } + + @Test + void resolvesLegacyPlayerNameKeys() throws Exception { + File file = new File(temporaryDirectory, "currentHomes.yml"); + UUID playerId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("OldPlayerName", 2); + yaml.save(file); + + assertEquals(Map.of(playerId, 2), CurrentHomesStore.load(file, name -> { + assertEquals("OldPlayerName", name); + return playerId; + })); + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlayerHomeTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlayerHomeTest.java new file mode 100644 index 0000000..bf63338 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlayerHomeTest.java @@ -0,0 +1,27 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.sk89q.worldedit.math.BlockVector3; + +class PlayerHomeTest { + @Test + void buildsUuidBasedRegionIdentityAndPreservesDisplayName() { + UUID playerId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + PlayerHome home = new PlayerHome( + playerId, + "PlayerName", + 2, + BlockVector3.at(1, 2, 3), + BlockVector3.at(4, 5, 6)); + + assertEquals(playerId, home.getPlayerId()); + assertEquals("PlayerName", home.getPlayerName()); + assertEquals(2, home.getNumber()); + assertEquals("h_2_" + playerId, home.getRegionName()); + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlayerLookupTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlayerLookupTest.java new file mode 100644 index 0000000..7964faf --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/PlayerLookupTest.java @@ -0,0 +1,22 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class PlayerLookupTest { + @Test + void matchesOnlinePlayerNamesCaseInsensitivelyButNotByPrefix() { + Player player = mock(Player.class); + when(player.getName()).thenReturn("PlayerName"); + + assertSame(player, PlayerLookup.findOnline("playername", List.of(player))); + assertNull(PlayerLookup.findOnline("Player", List.of(player))); + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java new file mode 100644 index 0000000..778a5dc --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisHomeAcquireTest.java @@ -0,0 +1,48 @@ +package com.majinnaibu.bukkitplugins.metropolis.commands; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.UUID; + +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.majinnaibu.bukkitplugins.metropolis.MetropolisPlugin; + +class MetropolisHomeAcquireTest { + @Test + void assignsAPlotWhenThePlayerIsBelowTheirLimit() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + Player player = mock(Player.class); + UUID playerId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + when(player.getUniqueId()).thenReturn(playerId); + when(plugin.getNumPlots(playerId)).thenReturn(0); + when(plugin.getMaxPlots(playerId)).thenReturn(1); + MetropolisHomeAcquire executor = new MetropolisHomeAcquire(plugin); + + assertTrue(executor.onCommand(player, mock(Command.class), "acquire", new String[0])); + + verify(plugin).assignPlot(player); + } + + @Test + void doesNotAssignAPlotAfterThePlayerReachesTheirLimit() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + Player player = mock(Player.class); + UUID playerId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + when(player.getUniqueId()).thenReturn(playerId); + when(plugin.getNumPlots(playerId)).thenReturn(1); + when(plugin.getMaxPlots(playerId)).thenReturn(1); + MetropolisHomeAcquire executor = new MetropolisHomeAcquire(plugin); + + assertFalse(executor.onCommand(player, mock(Command.class), "acquire", new String[0])); + + verify(plugin, never()).assignPlot(player); + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisPlotReserveCommandTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisPlotReserveCommandTest.java new file mode 100644 index 0000000..15ed429 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/commands/MetropolisPlotReserveCommandTest.java @@ -0,0 +1,49 @@ +package com.majinnaibu.bukkitplugins.metropolis.commands; + +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.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.majinnaibu.bukkitplugins.metropolis.Cuboid; +import com.majinnaibu.bukkitplugins.metropolis.MetropolisPlugin; + +class MetropolisPlotReserveCommandTest { + @Test + void reservesCoordinatesFromAllSixArguments() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + CommandSender sender = mock(CommandSender.class); + Command command = mock(Command.class); + MetropolisPlotReserveCommand executor = new MetropolisPlotReserveCommand(plugin); + + assertTrue(executor.onCommand(sender, command, "reserve", new String[] {"north", "1", "2", "3", "4", "5", "6"})); + + ArgumentCaptor cuboid = ArgumentCaptor.forClass(Cuboid.class); + verify(plugin).reserveCuboid(eq("north"), cuboid.capture()); + assertEquals(1, cuboid.getValue().getMinX()); + assertEquals(2, cuboid.getValue().getMinY()); + assertEquals(3, cuboid.getValue().getMinZ()); + assertEquals(4, cuboid.getValue().getMaxX()); + assertEquals(5, cuboid.getValue().getMaxY()); + assertEquals(6, cuboid.getValue().getMaxZ()); + } + + @Test + void rejectsMalformedCoordinateArgumentsWithoutCallingPlugin() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + CommandSender sender = mock(CommandSender.class); + Command command = mock(Command.class); + MetropolisPlotReserveCommand executor = new MetropolisPlotReserveCommand(plugin); + + assertFalse(executor.onCommand(sender, command, "reserve", new String[] {"north", "1", "two", "3", "4", "5", "6"})); + verifyNoInteractions(plugin); + } +} \ No newline at end of file diff --git a/src/test/java/com/majinnaibu/bukkitplugins/metropolis/eventlisteners/PlayerJoinListenerTest.java b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/eventlisteners/PlayerJoinListenerTest.java new file mode 100644 index 0000000..b202091 --- /dev/null +++ b/src/test/java/com/majinnaibu/bukkitplugins/metropolis/eventlisteners/PlayerJoinListenerTest.java @@ -0,0 +1,37 @@ +package com.majinnaibu.bukkitplugins.metropolis.eventlisteners; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; + +import com.majinnaibu.bukkitplugins.metropolis.MetropolisPlugin; +import com.majinnaibu.bukkitplugins.metropolis.PlayerHome; +import com.sk89q.worldedit.math.BlockVector3; + +class PlayerJoinListenerTest { + @Test + void tellsJoiningPlayerTheirHomeBounds() { + MetropolisPlugin plugin = mock(MetropolisPlugin.class); + Player player = mock(Player.class); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + when(player.getName()).thenReturn("PlayerName"); + when(plugin.getPlayerHome(player)).thenReturn(new PlayerHome( + UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + "PlayerName", + 1, + BlockVector3.at(1, 2, 3), + BlockVector3.at(4, 5, 6))); + + new PlayerJoinListener(plugin).onPlayerJoin(event); + + verify(player).sendMessage(contains("home is between (1, 2, 3) and (4, 5, 6)")); + } +} \ No newline at end of file