Add server-free plugin behavior tests

This commit is contained in:
2026-09-26 22:53:58 -07:00
parent ba96b8a73a
commit c760f5ce46
12 changed files with 358 additions and 39 deletions

View File

@@ -17,7 +17,7 @@
| MET-013 | Done | Replace Player-keyed storage with UUID-based storage wherever player identity is persisted or tracked. | | 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-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-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-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-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. | | MET-019 | Ready | Address bugs and incompatibilities found by unit tests, integration tests, or modern Paper server testing. |

View File

@@ -21,6 +21,10 @@ dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
testImplementation 'org.mockito:mockito-junit-jupiter:5.24.0' 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 { java {

View File

@@ -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<UUID, Integer> load(File file, Function<String, UUID> legacyOwnerResolver)
throws IOException, InvalidConfigurationException {
Map<UUID, Integer> 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<UUID, Integer> 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);
}
}

View File

@@ -21,8 +21,6 @@ import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.Configuration; import org.bukkit.configuration.Configuration;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginDescriptionFile; import org.bukkit.plugin.PluginDescriptionFile;
@@ -302,34 +300,13 @@ public class MetropolisPlugin extends JavaPlugin {
private void loadCurrentHomes() { private void loadCurrentHomes() {
File homesFile = new File(getDataFolder(), "currentHomes.yml"); File homesFile = new File(getDataFolder(), "currentHomes.yml");
if (!homesFile.exists()) {
return;
}
YamlConfiguration homes = new YamlConfiguration();
try { try {
homes.load(homesFile); _currentHomes.putAll(CurrentHomesStore.load(
} catch (IOException | InvalidConfigurationException e) { homesFile,
getLogger().log(java.util.logging.Level.SEVERE, "Unable to load currentHomes.yml", e); ownerName -> getServer().getOfflinePlayer(ownerName).getUniqueId()));
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) {
saveCurrentHomes(); 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) { if (player != null) {
return player; return player;
} }
for (Player onlinePlayer : getServer().getOnlinePlayers()) { return PlayerLookup.findOnline(name, getServer().getOnlinePlayers());
if (onlinePlayer.getName().equalsIgnoreCase(name)) {
return onlinePlayer;
}
}
return null;
} }
public OfflinePlayer getOfflinePlayer(String name){ public OfflinePlayer getOfflinePlayer(String name){
@@ -1167,11 +1139,8 @@ public class MetropolisPlugin extends JavaPlugin {
private void saveCurrentHomes() { private void saveCurrentHomes() {
File homesFile = new File(getDataFolder(), "currentHomes.yml"); File homesFile = new File(getDataFolder(), "currentHomes.yml");
YamlConfiguration homes = new YamlConfiguration();
_currentHomes.forEach((playerId, homeNumber) -> homes.set(playerId.toString(), homeNumber));
try { try {
getDataFolder().mkdirs(); CurrentHomesStore.save(homesFile, _currentHomes);
homes.save(homesFile);
} catch (IOException e) { } catch (IOException e) {
getLogger().log(java.util.logging.Level.SEVERE, "Unable to save currentHomes.yml", e); getLogger().log(java.util.logging.Level.SEVERE, "Unable to save currentHomes.yml", e);
} }

View File

@@ -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<? extends Player> onlinePlayers) {
if (requestedName == null) {
return null;
}
for (Player player : onlinePlayers) {
if (requestedName.equalsIgnoreCase(player.getName())) {
return player;
}
}
return null;
}
}

View File

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

View File

@@ -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<UUID, Integer> 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;
}));
}
}

View File

@@ -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());
}
}

View File

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

View File

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

View File

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

View File

@@ -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)"));
}
}