diff --git a/TODO.md b/TODO.md index b30af72..ee92b4d 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ | 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 | 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 | Done | 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. | | MET-020 | Ready | Adopt the git-sensitive semantic versioning Gradle plugin used by MobScores and ScoreKeeper, using the existing 0.5-SNAPSHOT version as the migration baseline; verify version/tag behavior and enable the Gradle configuration cache when compatible. | diff --git a/build.gradle b/build.gradle index 36d51e1..ea9fda0 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,18 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.13.4' } +sourceSets { + integrationTest { + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + +configurations { + integrationTestImplementation.extendsFrom testImplementation + integrationTestRuntimeOnly.extendsFrom testRuntimeOnly +} + java { toolchain { languageVersion = JavaLanguageVersion.of(25) @@ -54,6 +66,15 @@ tasks.named('test') { useJUnitPlatform() } +tasks.register('integrationTest', Test) { + description = 'Runs opt-in tests against a running Paper server with RCON enabled.' + group = 'verification' + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + useJUnitPlatform() + shouldRunAfter tasks.named('test') +} + tasks.named('wrapper') { gradleVersion = '9.8.0' distributionType = Wrapper.DistributionType.BIN diff --git a/src/integrationTest/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisServerIntegrationTest.java b/src/integrationTest/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisServerIntegrationTest.java new file mode 100644 index 0000000..e7ad99f --- /dev/null +++ b/src/integrationTest/java/com/majinnaibu/bukkitplugins/metropolis/MetropolisServerIntegrationTest.java @@ -0,0 +1,126 @@ +package com.majinnaibu.bukkitplugins.metropolis; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.UUID; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class MetropolisServerIntegrationTest { + @Test + void runningPaperLoadsMetropolisAndDispatchesItsVersionCommand() throws IOException { + try (RconClient rcon = connectToConfiguredServer()) { + String installedPlugins = rcon.execute("plugins"); + String versionResponse = rcon.execute("metropolis"); + + assertTrue(installedPlugins.contains("Metropolis"), installedPlugins); + assertTrue(versionResponse.contains("Metropolis: version"), versionResponse); + } + } + + @Test + void plotReservationIsPersistedByTheLoadedWorldGuardPlugin() throws IOException { + String regionId = "metropolis-it-" + UUID.randomUUID().toString().substring(0, 8); + try (RconClient rcon = connectToConfiguredServer()) { + String response = rcon.execute("metropolis-plot-reserve " + regionId + " 100 60 100 105 70 105"); + assertTrue(response.contains("Reserved plot region " + regionId), response); + } + + Path serverDirectory = Path.of(requiredEnvironment("METROPOLIS_SERVER_DIR")); + String worldName = System.getenv().getOrDefault("METROPOLIS_WORLD", "world"); + Path regionsFile = serverDirectory.resolve("plugins/WorldGuard/worlds") + .resolve(worldName) + .resolve("regions.yml"); + YamlConfiguration regions = YamlConfiguration.loadConfiguration(regionsFile.toFile()); + + assertTrue(regions.contains("regions." + regionId), "WorldGuard did not persist region " + regionId); + } + + private static RconClient connectToConfiguredServer() throws IOException { + String host = requiredEnvironment("METROPOLIS_RCON_HOST"); + int port = Integer.parseInt(requiredEnvironment("METROPOLIS_RCON_PORT")); + String password = requiredEnvironment("METROPOLIS_RCON_PASSWORD"); + return new RconClient(host, port, password); + } + + private static String requiredEnvironment(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Set " + name + " to run integrationTest."); + } + return value; + } + + private static final class RconClient implements AutoCloseable { + private final Socket socket = new Socket(); + private final DataInputStream input; + private final DataOutputStream output; + private int requestId = 1; + + private RconClient(String host, int port, String password) throws IOException { + socket.connect(new InetSocketAddress(host, port), 5000); + socket.setSoTimeout(5000); + input = new DataInputStream(socket.getInputStream()); + output = new DataOutputStream(socket.getOutputStream()); + int authId = requestId++; + Packet response = send(authId, 3, password); + if (response.id != authId) { + throw new IOException("RCON authentication failed."); + } + } + + private String execute(String command) throws IOException { + return send(requestId++, 2, command).payload; + } + + private Packet send(int id, int type, String payload) throws IOException { + byte[] data = payload.getBytes(StandardCharsets.UTF_8); + writeLittleEndianInt(data.length + 10); + writeLittleEndianInt(id); + writeLittleEndianInt(type); + output.write(data); + output.writeByte(0); + output.writeByte(0); + output.flush(); + + int length = readLittleEndianInt(); + byte[] packet = input.readNBytes(length); + if (packet.length != length || length < 10) { + throw new IOException("Received an invalid RCON packet."); + } + int responseId = littleEndianInt(packet, 0); + String response = new String(packet, 8, length - 10, StandardCharsets.UTF_8); + return new Packet(responseId, response); + } + + private void writeLittleEndianInt(int value) throws IOException { + output.writeInt(Integer.reverseBytes(value)); + } + + private int readLittleEndianInt() throws IOException { + return Integer.reverseBytes(input.readInt()); + } + + private static int littleEndianInt(byte[] bytes, int offset) { + return (bytes[offset] & 0xff) + | ((bytes[offset + 1] & 0xff) << 8) + | ((bytes[offset + 2] & 0xff) << 16) + | (bytes[offset + 3] << 24); + } + + @Override + public void close() throws IOException { + socket.close(); + } + } + + private record Packet(int id, String payload) {} +} \ No newline at end of file