From 5782506794ee09692c0d4206c3c1259c9edcc057 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Fri, 2 Oct 2026 10:02:02 +0000
Subject: [PATCH 1/2] test: enforce full runtime coverage and fix web
regressions
---
.github/workflows/build.yml | 8 +
.github/workflows/maven-release.yml | 8 +
README.md | 9 +
pom.xml | 64 ++++++-
.../java/net/tfminecraft/tfmcweb/TFMCWeb.java | 2 +-
.../tfmcweb/api/ProvinceSystemClient.java | 86 ++--------
.../tfmcweb/api/ProvinceSystemGateway.java | 148 ++++++----------
.../entitlements/PlayerMetaSyncService.java | 17 +-
.../listeners/EssentialsBanListener.java | 3 -
.../tfmcweb/loaders/ConfigLoader.java | 3 -
.../loaders/PlayerMetaConfigLoader.java | 8 +-
.../ess3/api/events/BanStatusChangeEvent.java | 17 ++
.../tfmcweb/RuntimeUtilitiesTest.java | 73 ++++++++
.../tfmcweb/TFMCWebLifecycleTest.java | 50 ++++++
.../net/tfminecraft/tfmcweb/TestState.java | 27 +++
.../tfminecraft/tfmcweb/api/HttpFixture.java | 42 +++++
.../tfmcweb/api/ProvinceSystemClientTest.java | 162 ++++++++++++++++++
.../api/ProvinceSystemGatewayTest.java | 108 ++++++++++++
.../entitlements/EntitlementsTest.java | 96 +++++++++++
.../tfmcweb/gate/DiscordGateServiceTest.java | 43 +++++
.../listeners/EssentialsBanListenerTest.java | 93 ++++++++++
.../tfmcweb/loaders/ConfigurationTest.java | 128 ++++++++++++++
.../tfmcweb/managers/CommandFixture.java | 43 +++++
.../tfmcweb/managers/NoticeAndJoinTest.java | 74 ++++++++
.../managers/PlayerAndAdminCommandsTest.java | 98 +++++++++++
.../tfmcweb/managers/TokenCommandTest.java | 80 +++++++++
26 files changed, 1293 insertions(+), 197 deletions(-)
create mode 100644 src/test/java/net/ess3/api/events/BanStatusChangeEvent.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/TestState.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
create mode 100644 src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 495c41d..48d130d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -74,3 +74,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..337486b 100644
--- a/.github/workflows/maven-release.yml
+++ b/.github/workflows/maven-release.yml
@@ -73,6 +73,14 @@ jobs:
echo "jar=target/$final_name.jar" >> "$GITHUB_OUTPUT"
mvn -B --no-transfer-progress clean verify -DskipTests=false -Dmaven.test.skip=false
+ - 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 c8c1d59..54c62f9 100644
--- a/README.md
+++ b/README.md
@@ -30,3 +30,12 @@ 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. The build runs the unit tests and enforces
+100% executable runtime **line coverage** with JaCoCo, without production-class
+exclusions. Instruction and branch coverage are reported separately.
+
+The HTML report is `target/site/jacoco/index.html`; the machine-readable report is
+`target/site/jacoco/jacoco.xml`. CI uploads these reports alongside test results.
diff --git a/pom.xml b/pom.xml
index f1f7f3c..39227dd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,6 +9,7 @@
main-SNAPSHOT
+
21
UTF-8
full
@@ -16,6 +17,18 @@
+
+ org.slf4j
+ slf4j-simple
+ 2.0.17
+ test
+
+
+ com.google.code.gson
+ gson
+ 2.11.0
+ provided
+
io.papermc.paper
paper-api
@@ -45,13 +58,19 @@
org.junit.jupiter
junit-jupiter
- 5.11.4
+ 5.14.0
test
org.mockito
mockito-core
- 5.14.2
+ 5.23.0
+ test
+
+
+ org.mockbukkit.mockbukkit
+ mockbukkit-v1.21
+ 4.95.0
test
@@ -101,11 +120,48 @@
org.apache.maven.plugins
maven-surefire-plugin
- 3.5.2
+ 3.5.4
- -javaagent:${org.mockito:mockito-core:jar} -Xshare:off
+ @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off
+ true
+
+
+ org.slf4j.simple.SimpleServiceProvider
+
+
+ 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/tfmcweb/TFMCWeb.java b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java
index ea147fb..6553b0f 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java
@@ -139,6 +139,6 @@ public static String getRealmId() {
if (realm == null || realm.isBlank()) {
return "main";
}
- return realm.trim().toLowerCase();
+ return realm.trim().toLowerCase(java.util.Locale.ROOT);
}
}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java
index da0a27b..b46223c 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClient.java
@@ -11,6 +11,10 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
import net.tfminecraft.tfmcweb.Cache;
@@ -277,7 +281,7 @@ public static CodeResult startDiscordLink(String playerUuid, String minecraftNam
public static FeatureCodeResult issueFeatureCode(String playerUuid, String scope) {
String uuid = playerUuid == null ? "" : playerUuid.trim();
- String sc = scope == null ? "" : scope.trim().toLowerCase();
+ String sc = scope == null ? "" : scope.trim().toLowerCase(Locale.ROOT);
if (uuid.isEmpty()) {
return FeatureCodeResult.fail("player_uuid is required");
}
@@ -289,7 +293,7 @@ public static FeatureCodeResult issueFeatureCode(String playerUuid, String scope
}
String realm = Cache.realmId == null || Cache.realmId.isBlank()
? "main"
- : Cache.realmId.trim().toLowerCase();
+ : Cache.realmId.trim().toLowerCase(Locale.ROOT);
String body = "{"
+ "\"player_uuid\":\"" + escapeJson(uuid) + "\","
+ "\"scope\":\"" + escapeJson(sc) + "\","
@@ -410,7 +414,7 @@ public static MirrorResult postBanEvent(
String duration,
String staffName
) {
- String etype = event == null ? "" : event.trim().toLowerCase();
+ String etype = event == null ? "" : event.trim().toLowerCase(Locale.ROOT);
if (!"ban".equals(etype) && !"unban".equals(etype)) {
return MirrorResult.fail("event must be ban or unban");
}
@@ -940,79 +944,21 @@ static String jsonString(String json, String key) {
if (json == null || key == null) {
return null;
}
- String needle = "\"" + key + "\"";
- int keyIdx = json.indexOf(needle);
- if (keyIdx < 0) {
- return null;
- }
- int colon = json.indexOf(':', keyIdx + needle.length());
- if (colon < 0) {
- return null;
- }
- int i = colon + 1;
- while (i < json.length() && Character.isWhitespace(json.charAt(i))) {
- i++;
- }
- if (i >= json.length()) {
- return null;
- }
- char c = json.charAt(i);
- if (c == '"') {
- StringBuilder out = new StringBuilder();
- i++;
- while (i < json.length()) {
- char ch = json.charAt(i++);
- if (ch == '\\' && i < json.length()) {
- out.append(json.charAt(i++));
- continue;
- }
- if (ch == '"') {
- break;
- }
- out.append(ch);
+ try {
+ JsonElement root = JsonParser.parseString(json);
+ if (!root.isJsonObject()) {
+ return null;
}
- return out.toString();
- }
- if (c == 'n' && json.startsWith("null", i)) {
+ JsonElement value = root.getAsJsonObject().get(key);
+ return value != null && value.isJsonPrimitive() ? value.getAsString() : null;
+ } catch (com.google.gson.JsonParseException e) {
return null;
}
- int start = i;
- while (i < json.length()) {
- char ch = json.charAt(i);
- if (ch == ',' || ch == '}' || ch == ']') {
- break;
- }
- i++;
- }
- return json.substring(start, i).trim();
}
static String escapeJson(String raw) {
- if (raw == null) {
- return "";
- }
- StringBuilder sb = new StringBuilder(raw.length() + 8);
- for (int i = 0; i < raw.length(); i++) {
- char ch = raw.charAt(i);
- switch (ch) {
- case '\\':
- case '"':
- sb.append('\\').append(ch);
- break;
- case '\n':
- sb.append("\\n");
- break;
- case '\r':
- sb.append("\\r");
- break;
- case '\t':
- sb.append("\\t");
- break;
- default:
- sb.append(ch);
- }
- }
- return sb.toString();
+ String encoded = new Gson().toJson(raw == null ? "" : raw);
+ return encoded.substring(1, encoded.length() - 1);
}
private static String readBody(InputStream stream) throws Exception {
diff --git a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java
index 1c66089..2ac1256 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGateway.java
@@ -10,8 +10,13 @@
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
import net.tfminecraft.tfmcweb.Cache;
import net.tfminecraft.tfmcweb.TFMCWeb;
@@ -24,9 +29,6 @@ public final class ProvinceSystemGateway {
private static final int TIMEOUT_MS = 8000;
private static final int DOWNLOAD_TIMEOUT_MS = 30000;
- private static final Pattern REALM_IN_BODY = Pattern.compile(
- "\"realm_id\"\\s*:"
- );
private ProvinceSystemGateway() {}
@@ -169,15 +171,11 @@ static String injectRealmPath(String method, String path) {
if (queryContainsRealm(query)) {
return path;
}
- try {
- String enc = URLEncoder.encode(realm, StandardCharsets.UTF_8);
- if (q >= 0) {
- return path + (query.isEmpty() ? "" : "&") + "realm_id=" + enc;
- }
- return path + "?realm_id=" + enc;
- } catch (Exception e) {
- return path + (q >= 0 ? "&" : "?") + "realm_id=" + realm;
+ String enc = URLEncoder.encode(realm, StandardCharsets.UTF_8);
+ if (q >= 0) {
+ return path + (query.isEmpty() ? "" : "&") + "realm_id=" + enc;
}
+ return path + "?realm_id=" + enc;
}
static String injectRealmBody(String method, String path, String jsonBody) {
@@ -192,19 +190,17 @@ static String injectRealmBody(String method, String path, String jsonBody) {
if (!needsRealmBody(method, bare)) {
return jsonBody;
}
- if (REALM_IN_BODY.matcher(jsonBody).find()) {
- return jsonBody;
- }
- String realm = TFMCWeb.getRealmId();
- String trimmed = jsonBody.trim();
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
+ try {
+ JsonElement parsed = JsonParser.parseString(jsonBody);
+ if (!parsed.isJsonObject() || parsed.getAsJsonObject().has("realm_id")) {
+ return jsonBody;
+ }
+ // Only the top-level field scopes the request; nested metadata is independent.
+ parsed.getAsJsonObject().addProperty("realm_id", TFMCWeb.getRealmId());
+ return parsed.toString();
+ } catch (com.google.gson.JsonParseException e) {
return jsonBody;
}
- String inner = trimmed.substring(1, trimmed.length() - 1).trim();
- if (inner.isEmpty()) {
- return "{\"realm_id\":\"" + escapeJson(realm) + "\"}";
- }
- return "{\"realm_id\":\"" + escapeJson(realm) + "\"," + inner + "}";
}
private static boolean needsRealmQuery(String method, String barePath) {
@@ -233,9 +229,6 @@ private static boolean needsRealmBody(String method, String barePath) {
}
private static boolean equalsPath(String bare, String expected) {
- if (bare == null) {
- return false;
- }
String p = bare.trim();
if (p.endsWith("/") && p.length() > 1) {
p = p.substring(0, p.length() - 1);
@@ -273,13 +266,17 @@ private static GatewayResult execute(
return GatewayResult.fail(notConfiguredMessage());
}
+ if ("PATCH".equals(method)) {
+ return executePatch(base, key, path, body, contentType, readTimeoutMs);
+ }
+
HttpURLConnection connection = null;
try {
String root = trimSlash(base);
@SuppressWarnings("deprecation")
URL url = new URL(root + path);
connection = (HttpURLConnection) url.openConnection();
- applyRequestMethod(connection, method);
+ connection.setRequestMethod(method.isBlank() ? "GET" : method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(readTimeoutMs);
connection.setRequestProperty("X-Plugin-Key", key);
@@ -317,31 +314,33 @@ private static GatewayResult execute(
}
private static String trimSlash(String base) {
- if (base == null) {
- return "";
- }
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
}
- /**
- * {@link HttpURLConnection} rejects PATCH on many JVMs ({@code Invalid HTTP method: PATCH}).
- */
- private static void applyRequestMethod(HttpURLConnection connection, String method)
- throws java.net.ProtocolException {
- String m = method == null || method.isBlank() ? "GET" : method.trim().toUpperCase(Locale.ROOT);
- try {
- connection.setRequestMethod(m);
- } catch (java.net.ProtocolException ex) {
- if (!"PATCH".equals(m)) {
- throw ex;
- }
- try {
- var field = HttpURLConnection.class.getDeclaredField("method");
- field.setAccessible(true);
- field.set(connection, "PATCH");
- } catch (ReflectiveOperationException roe) {
- throw ex;
+ /** Use the supported JDK HTTP API for PATCH, which HttpURLConnection rejects. */
+ private static GatewayResult executePatch(String base, String key, String path,
+ byte[] body, String contentType, int readTimeoutMs) {
+ try (HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofMillis(TIMEOUT_MS)).build()) {
+ HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(trimSlash(base) + path))
+ .timeout(Duration.ofMillis(readTimeoutMs))
+ .header("X-Plugin-Key", key).header("Accept", "application/json");
+ if (contentType != null) {
+ request.header("Content-Type", contentType);
}
+ request.method("PATCH", body == null ? HttpRequest.BodyPublishers.noBody()
+ : HttpRequest.BodyPublishers.ofByteArray(body));
+ HttpResponse response = client.send(request.build(),
+ HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ int status = response.statusCode();
+ return status >= 200 && status < 300
+ ? GatewayResult.success(status, response.body())
+ : GatewayResult.fail(status, detailOrHttp(response.body(), status));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return GatewayResult.fail("Could not reach API: request interrupted");
+ } catch (Exception e) {
+ return GatewayResult.fail("Could not reach API: " + e.getMessage());
}
}
@@ -350,7 +349,7 @@ private static String notConfiguredMessage() {
}
private static String detailOrHttp(String response, int status) {
- String detail = extractJsonString(response, "detail");
+ String detail = ProvinceSystemClient.jsonString(response, "detail");
if (detail == null || detail.isEmpty()) {
detail = response == null || response.isEmpty()
? ("HTTP " + status)
@@ -362,52 +361,6 @@ private static String detailOrHttp(String response, int status) {
return detail;
}
- private static String extractJsonString(String json, String key) {
- if (json == null || key == null) {
- return null;
- }
- Matcher m = Pattern.compile(
- "\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\""
- ).matcher(json);
- if (!m.find()) {
- return null;
- }
- return unescape(m.group(1));
- }
-
- private static String unescape(String raw) {
- if (raw == null) {
- return null;
- }
- StringBuilder out = new StringBuilder(raw.length());
- for (int i = 0; i < raw.length(); i++) {
- char c = raw.charAt(i);
- if (c == '\\' && i + 1 < raw.length()) {
- char n = raw.charAt(++i);
- out.append(n == 'n' ? '\n' : n == 't' ? '\t' : n == 'r' ? '\r' : n);
- } else {
- out.append(c);
- }
- }
- return out.toString();
- }
-
- private static String escapeJson(String raw) {
- if (raw == null) {
- return "";
- }
- StringBuilder out = new StringBuilder(raw.length() + 8);
- for (int i = 0; i < raw.length(); i++) {
- char c = raw.charAt(i);
- switch (c) {
- case '\\' -> out.append("\\\\");
- case '"' -> out.append("\\\"");
- default -> out.append(c);
- }
- }
- return out.toString();
- }
-
private static String readBody(InputStream stream) throws Exception {
if (stream == null) {
return "";
@@ -428,9 +381,6 @@ private static String readBody(InputStream stream) throws Exception {
}
private static byte[] readBytes(InputStream stream) throws Exception {
- if (stream == null) {
- return new byte[0];
- }
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
diff --git a/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java b/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java
index aab143a..dff942e 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/entitlements/PlayerMetaSyncService.java
@@ -87,7 +87,7 @@ static String toJson(UUID playerUuid, ResolvedMeta meta) {
sb.append(',');
}
first = false;
- sb.append('"').append(escape(kind.trim().toLowerCase())).append('"');
+ sb.append('"').append(escape(kind.trim().toLowerCase(java.util.Locale.ROOT))).append('"');
}
sb.append(']');
sb.append(",\"allow_armor_3d_helmet\":").append(meta.allowArmor3dHelmet);
@@ -110,18 +110,7 @@ static String toJson(UUID playerUuid, ResolvedMeta meta) {
}
private static String escape(String raw) {
- if (raw == null) {
- return "";
- }
- StringBuilder out = new StringBuilder(raw.length() + 8);
- for (int i = 0; i < raw.length(); i++) {
- char c = raw.charAt(i);
- switch (c) {
- case '\\' -> out.append("\\\\");
- case '"' -> out.append("\\\"");
- default -> out.append(c);
- }
- }
- return out.toString();
+ String encoded = new com.google.gson.Gson().toJson(raw);
+ return encoded.substring(1, encoded.length() - 1);
}
}
diff --git a/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java b/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java
index 38cc8c4..2b4faf4 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListener.java
@@ -256,9 +256,6 @@ private static String readControllerName(Event event) throws Exception {
}
private static Object tryInvoke(Object target, String method) throws Exception {
- if (target == null) {
- return null;
- }
try {
Method m = target.getClass().getMethod(method);
return m.invoke(target);
diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java
index ebfbafb..8710186 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java
@@ -25,9 +25,6 @@ public void load(File configFile) {
}
String base = config.getString("api.base-url", "");
- if (base == null) {
- base = "";
- }
base = base.trim();
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java
index a24ae5f..c202de4 100644
--- a/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java
+++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/PlayerMetaConfigLoader.java
@@ -117,9 +117,11 @@ private static void applyLadderSection(ConfigurationSection root) {
skinsDefaults,
Cache.skinsMetaDefaults
);
- Cache.skinsMetaDefaultKinds = List.copyOf(
- readStringList(skinsDefaults.getList("skin-kinds"))
- );
+ if (skinsDefaults.contains("skin-kinds")) {
+ Cache.skinsMetaDefaultKinds = List.copyOf(
+ readStringList(skinsDefaults.getList("skin-kinds"))
+ );
+ }
Cache.skinsMetaDefaultAllowArmor3dHelmet = skinsDefaults.getBoolean(
"allow-armor-3d-helmet",
Cache.skinsMetaDefaultAllowArmor3dHelmet
diff --git a/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java
new file mode 100644
index 0000000..f459b05
--- /dev/null
+++ b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java
@@ -0,0 +1,17 @@
+package net.ess3.api.events;
+
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+
+/** Test-only shape for the optional Essentials reflective integration. */
+public class BanStatusChangeEvent extends Event {
+ public Object value, banned, affected, entry, banEntry, controller, name;
+ public Object getValue(){return value;}
+ public Object isBanned(){return banned;}
+ public Object getAffected(){return affected;}
+ public Object getEntry(){return entry;}
+ public Object getBanEntry(){return banEntry;}
+ public Object getController(){return controller;}
+ public Object getName(){return name;}
+ @Override public HandlerList getHandlers(){return new HandlerList();}
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java
new file mode 100644
index 0000000..231808c
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java
@@ -0,0 +1,73 @@
+package net.tfminecraft.tfmcweb;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.time.Instant;
+import java.util.*;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.cache.LinkCache;
+import net.tfminecraft.tfmcweb.managers.TokenCooldownService;
+import net.tfminecraft.tfmcweb.utils.*;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.*;
+import org.mockito.ArgumentCaptor;
+
+class RuntimeUtilitiesTest {
+ TestState state;
+ @BeforeEach void setup() throws Exception {state=new TestState();}
+ @AfterEach void cleanup() throws Exception {state.close();}
+ @Test void cacheTracksIdentityGraceAndEligibility() {
+ LinkCache cache=new LinkCache();UUID id=UUID.randomUUID();
+ assertNull(cache.get(null));assertNull(cache.get(id));assertFalse(cache.isEligible(id));
+ cache.put(null,LinkCache.Entry.unlinked());cache.put(id,null);assertEquals(0,cache.size());
+ cache.putFromStatus(id,null);assertFalse(cache.get(id).linked);
+ cache.putFromStatus(id,ProvinceSystemClient.IdentityStatus.fail("bad"));assertFalse(cache.isEligible(id));
+ cache.putFromStatus(id,ProvinceSystemClient.IdentityStatus.fromJson("{\"linked\":true,\"eligible\":true,\"in_grace\":true,\"discord_user_id\":\"d\",\"discord_username\":\"Ada\",\"grace_until\":\"soon\"}"));
+ assertTrue(cache.isEligible(id));assertEquals("soon",cache.get(id).graceUntil);
+ cache.putGrace(id,null,"later");assertEquals("d",cache.get(id).discordUserId);assertEquals("Ada",cache.get(id).discordUsername);assertTrue(cache.get(id).inGrace);
+ cache.clearGrace(id);assertFalse(cache.get(id).inGrace);assertEquals("Ada",cache.get(id).discordUsername);
+ cache.clear(null);cache.clear(id);cache.clearGrace(id);assertTrue(cache.get(id).linked);assertNull(cache.get(id).discordUserId);
+ cache.clear(id);cache.putGrace(id,null,"until");assertNull(cache.get(id).discordUserId);
+ cache.putGrace(id,"new","until");assertEquals("new",cache.get(id).discordUserId);
+ cache.putLinked(id,"discord","name");assertEquals("name",cache.get(id).discordUsername);assertEquals(1,cache.snapshot().size());
+ cache.putUnlinked(id);assertFalse(cache.isEligible(id));cache.clear(id);assertEquals(0,cache.size());
+ }
+ @Test void expiryLabelsHandlePastFutureAndMalformedDates() {
+ assertNull(ExpiryFormat.relativeLabel(null));assertNull(ExpiryFormat.relativeLabel(""));assertNull(ExpiryFormat.relativeLabel("bad"));
+ Instant now=Instant.now();assertEquals("Expired",ExpiryFormat.relativeLabel(now.minusSeconds(1).toString()));
+ assertEquals("Expires in ~2h",ExpiryFormat.relativeLabel(now.plusSeconds(7500).toString()));
+ assertEquals("Expires in ~30m",ExpiryFormat.relativeLabel(now.plusSeconds(1830).toString()));
+ assertEquals("Expires in ~1m",ExpiryFormat.relativeLabel(now.plusSeconds(5).toString()));
+ }
+ @Test void messagesIncludeInteractiveCopyCode() {
+ Player player=mock(Player.class);ChatMessages.info(player,"info");ChatMessages.error(player,"error");
+ verify(player).sendMessage(ChatMessages.PREFIX+"info");verify(player).sendMessage(ChatMessages.PREFIX+"§cerror");
+ ChatMessages.sendCopyableCode(player,"intro","ABC");verify(player).sendMessage(ChatMessages.PREFIX+"intro");
+ ArgumentCaptor message=ArgumentCaptor.forClass(Component.class);verify(player).sendMessage(message.capture());
+ assertEquals(ClickEvent.copyToClipboard("ABC"),message.getValue().children().get(1).clickEvent());
+ ChatMessages.sendCopyableCode(player,null,null);ChatMessages.sendCopyableCode(player,"","");verify(player,times(1)).sendMessage(any(Component.class));
+ }
+ @Test void cooldownPermissionOrderAndStatuses() {
+ Player player=mock(Player.class);UUID id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id);
+ Cache.tokenCooldownDefaultDays=-1;Cache.tokenCooldownGroups=Arrays.asList(null,new Cache.TokenCooldownGroup(null,1),new Cache.TokenCooldownGroup("rank.low",3),new Cache.TokenCooldownGroup("rank.high",1));
+ assertEquals(-1,TokenCooldownService.resolveCooldownDays(null));assertEquals(-1,TokenCooldownService.resolveCooldownDays(player));
+ assertEquals("Your rank cannot create drink tokens",TokenCooldownService.checkSharedMint(player,"DRINK"));
+ assertEquals("Your rank cannot create skin tokens",TokenCooldownService.checkSharedMint(player,null));
+ when(player.hasPermission("rank.low")).thenReturn(true);when(player.hasPermission("rank.high")).thenReturn(true);assertEquals(1,TokenCooldownService.resolveCooldownDays(player));
+ Cache.tokenCooldownGroups=List.of();Cache.tokenCooldownDefaultDays=0;assertNull(TokenCooldownService.checkSharedMint(player,"skin"));Cache.tokenCooldownDefaultDays=3;
+ try(var api=mockStatic(ProvinceSystemClient.class)){
+ for(String value:Arrays.asList(null," ","bad","2000-01-01 00:00:00","2000-01-01T00:00:00")){
+ api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.success(value));assertNull(TokenCooldownService.checkSharedMint(player,"skin"));
+ }
+ api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.fail("down"));assertEquals("down",TokenCooldownService.checkSharedMint(player,"skin"));
+ api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.fail(null));assertEquals("Could not check mint cooldown.",TokenCooldownService.checkSharedMint(player,"skin"));
+ for(long remaining:new long[]{1800,7200,180000}){
+ api.when(()->ProvinceSystemClient.getCosmeticMintStatus(id.toString())).thenReturn(ProvinceSystemClient.CosmeticMintStatus.success(Instant.now().minusSeconds(3*86400-remaining).toString()));
+ String label=TokenCooldownService.checkSharedMint(player,"skin");assertTrue(label.startsWith("Token cooldown: try again in "));
+ if(remaining==1800)assertTrue(label.endsWith("1h"));if(remaining==180000)assertTrue(label.contains("d "));
+ }
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java
new file mode 100644
index 0000000..4a762b8
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java
@@ -0,0 +1,50 @@
+package net.tfminecraft.tfmcweb;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.nio.file.*;
+import java.util.Comparator;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService;
+import net.tfminecraft.tfmcweb.gate.DiscordGateService;
+import net.tfminecraft.tfmcweb.managers.PluginNoticePoller;
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.PluginManager;
+import org.junit.jupiter.api.*;
+import org.mockbukkit.mockbukkit.MockBukkit;
+import org.mockbukkit.mockbukkit.ServerMock;
+
+class TFMCWebLifecycleTest {
+ TestState state;ServerMock server;
+ @BeforeEach void setup() throws Exception {state=new TestState();server=MockBukkit.mock();}
+ @AfterEach void cleanup() throws Exception {MockBukkit.unmock();state.close();}
+ @Test void enableRegistersCommandsAndReloadsConfigurationAndDisableStopsPoller() throws Exception {
+ try(var pollers=mockConstruction(PluginNoticePoller.class);var sync=mockStatic(PlayerMetaSyncService.class)){
+ TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);assertSame(plugin,TFMCWeb.plugin);assertNotNull(plugin.getLinkCache());assertNotNull(plugin.getGateService());
+ for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning"})assertNotNull(plugin.getCommand(name).getExecutor());
+ assertNotNull(plugin.getCommand("web").getTabCompleter());assertNotNull(plugin.getCommand("token").getTabCompleter());
+ verify(pollers.constructed().getFirst()).start();assertTrue(TFMCWeb.isPresent());
+ Files.writeString(plugin.getDataFolder().toPath().resolve("config.yml"),"realm:\n id: dev\n");plugin.reloadLocalConfig();assertEquals("dev",TFMCWeb.getRealmId());
+ plugin.onDisable();verify(pollers.constructed().getFirst()).stop();
+ }
+ }
+ @Test void enableCreatesMissingDataDirectoryAndLogsMissingCommands() throws Exception {
+ try(var pollers=mockConstruction(PluginNoticePoller.class);var sync=mockStatic(PlayerMetaSyncService.class);var gates=mockConstruction(DiscordGateService.class,(mock,context)->when(mock.isRpcAvailable()).thenReturn(true))){
+ TFMCWeb plugin=MockBukkit.load(TFMCWeb.class);TFMCWeb spy=spy(plugin);
+ for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning"})doReturn(null).when(spy).getCommand(name);
+ try(var paths=Files.walk(plugin.getDataFolder().toPath())){for(Path path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);}
+ spy.onEnable();assertTrue(Files.isDirectory(plugin.getDataFolder().toPath()));assertTrue(Files.exists(plugin.getDataFolder().toPath().resolve("config.yml")));
+ spy.onDisable();verify(pollers.constructed().getLast()).stop();
+ }
+ }
+ @Test void presenceAndRealmDefaultsTolerateUnavailablePlugin() {
+ PluginManager plugins=mock(PluginManager.class);Plugin plugin=mock(Plugin.class);
+ try(var bukkit=mockStatic(Bukkit.class)){
+ bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);assertFalse(TFMCWeb.isPresent());
+ when(plugins.getPlugin("TFMCWeb")).thenReturn(plugin);assertFalse(TFMCWeb.isPresent());when(plugin.isEnabled()).thenReturn(true);assertTrue(TFMCWeb.isPresent());
+ Cache.realmId=null;assertEquals("main",TFMCWeb.getRealmId());Cache.realmId=" ";assertEquals("main",TFMCWeb.getRealmId());
+ }
+ TFMCWeb uninitialized=mock(TFMCWeb.class);doCallRealMethod().when(uninitialized).onDisable();uninitialized.onDisable();
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/TestState.java b/src/test/java/net/tfminecraft/tfmcweb/TestState.java
new file mode 100644
index 0000000..8ee965e
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/TestState.java
@@ -0,0 +1,27 @@
+package net.tfminecraft.tfmcweb;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/** Restores every mutable runtime setting even when a test fails. */
+public final class TestState implements AutoCloseable {
+ private final Map values = new LinkedHashMap<>();
+ private final Locale locale = Locale.getDefault();
+ public TestState() throws Exception {
+ for (Class> type : new Class>[]{Cache.class, TFMCWeb.class}) {
+ for (Field field : type.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers())) {
+ field.setAccessible(true);
+ values.put(field, field.get(null));
+ }
+ }
+ }
+ }
+ @Override public void close() throws Exception {
+ for (var entry : values.entrySet()) entry.getKey().set(null, entry.getValue());
+ Locale.setDefault(locale);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java b/src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java
new file mode 100644
index 0000000..3102cfe
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/api/HttpFixture.java
@@ -0,0 +1,42 @@
+package net.tfminecraft.tfmcweb.api;
+
+import com.sun.net.httpserver.HttpServer;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import net.tfminecraft.tfmcweb.Cache;
+
+final class HttpFixture implements AutoCloseable {
+ record Reply(int status, byte[] body) {}
+ record Request(String method, String path, String body, String key, String contentType) {}
+ private final HttpServer server;
+ private final LinkedBlockingQueue replies = new LinkedBlockingQueue<>();
+ private final LinkedBlockingQueue requests = new LinkedBlockingQueue<>();
+ HttpFixture() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ try {
+ requests.add(new Request(exchange.getRequestMethod(), exchange.getRequestURI().toString(),
+ new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8),
+ exchange.getRequestHeaders().getFirst("X-Plugin-Key"),
+ exchange.getRequestHeaders().getFirst("Content-Type")));
+ Reply reply = replies.poll();
+ if (reply == null) reply = new Reply(500, "Unexpected request".getBytes(StandardCharsets.UTF_8));
+ exchange.sendResponseHeaders(reply.status(), reply.body().length == 0 ? -1 : reply.body().length);
+ exchange.getResponseBody().write(reply.body());
+ } finally { exchange.close(); }
+ });
+ server.start();
+ Cache.apiBaseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
+ Cache.pluginKey = "unit-test-key";
+ }
+ void reply(int status, String body) { reply(status, body.getBytes(StandardCharsets.UTF_8)); }
+ void reply(int status, byte[] body) { replies.add(new Reply(status, body)); }
+ Request request() throws Exception {
+ Request request = requests.poll(2, TimeUnit.SECONDS);
+ if (request == null) throw new AssertionError("HTTP request not received");
+ return request;
+ }
+ @Override public void close() { server.stop(0); }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java
new file mode 100644
index 0000000..c3d247c
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemClientTest.java
@@ -0,0 +1,162 @@
+package net.tfminecraft.tfmcweb.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+import com.google.gson.JsonParser;
+import java.util.*;
+import net.tfminecraft.tfmcweb.Cache;
+import net.tfminecraft.tfmcweb.TestState;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*;
+import org.junit.jupiter.api.*;
+
+class ProvinceSystemClientTest {
+ TestState state;
+ HttpFixture http;
+ @BeforeEach void setup() throws Exception { state = new TestState(); http = new HttpFixture(); }
+ @AfterEach void cleanup() throws Exception { http.close(); state.close(); }
+
+ @Test void linkResponsesAndRequestEscaping() throws Exception {
+ http.reply(200, "{\"code\":\"ABC\",\"expires_at\":\"tomorrow\"}");
+ CodeResult result = ProvinceSystemClient.startDiscordLink("id", "name\"\\\n\r\t");
+ assertTrue(result.ok); assertFalse(result.alreadyLinked); assertEquals("ABC", result.code);
+ assertEquals("tomorrow", result.expiresAt); assertNull(result.error);
+ var req = http.request();
+ assertEquals("POST", req.method()); assertEquals("/skins/discord/link/start", req.path());
+ assertEquals("unit-test-key", req.key()); assertEquals("application/json", req.contentType());
+ assertEquals("name\"\\\n\r\t", JsonParser.parseString(req.body()).getAsJsonObject().get("minecraft_name").getAsString());
+ http.reply(200, "{\"already_linked\":true,\"discord_username\":\"ada\"}");
+ result = ProvinceSystemClient.startDiscordLink(null, null);
+ assertTrue(result.alreadyLinked); assertEquals("ada", result.discordUsername); http.request();
+ for (String body : List.of("{}", "{\"code\":\"\"}")) {
+ http.reply(200, body); assertFalse(ProvinceSystemClient.startDiscordLink("id", "a").ok); http.request();
+ }
+ }
+ @Test void featuresValidateScopeAndFallbackResponse() throws Exception {
+ assertFalse(ProvinceSystemClient.issueFeatureCode(null, null).ok);
+ assertFalse(ProvinceSystemClient.issueFeatureCode("id", null).ok);
+ assertFalse(ProvinceSystemClient.issueFeatureCode("id", "unknown").ok);
+ for (String scope : List.of("skin", "drink", "profile", "skin_staff")) {
+ Cache.realmId = " DEV ";
+ http.reply(200, "{\"code\":\"x\",\"expires_at\":\"soon\"}");
+ var result = ProvinceSystemClient.issueFeatureCode(" id ", scope.toUpperCase(Locale.ROOT));
+ assertTrue(result.ok); assertEquals(scope, result.scope); assertEquals("x", result.code); assertEquals("soon", result.expiresAt);
+ var body = JsonParser.parseString(http.request().body()).getAsJsonObject();
+ assertEquals("dev", body.get("realm_id").getAsString()); assertEquals("id", body.get("player_uuid").getAsString());
+ }
+ for (String realm : Arrays.asList(null, " ")) {
+ Cache.realmId = realm;
+ http.reply(200, "{\"code\":\"x\",\"scope\":\"profile\"}");
+ assertEquals("profile", ProvinceSystemClient.issueFeatureCode("id", "skin").scope);
+ assertTrue(http.request().body().contains("\"main\""));
+ }
+ for (String body : List.of("{}", "{\"code\":\"\",\"scope\":\"\"}")) {
+ http.reply(200, body); assertFalse(ProvinceSystemClient.issueFeatureCode("id", "skin").ok); http.request();
+ }
+ }
+ @Test void identityMintStatusAndValidation() throws Exception {
+ assertFalse(ProvinceSystemClient.getIdentityStatus(null).ok);
+ assertFalse(ProvinceSystemClient.getCosmeticMintStatus(" ").ok);
+ http.reply(200, "{\"linked\":true,\"eligible\":true,\"in_grace\":true,\"player_uuid\":\"id\",\"discord_user_id\":\"d\",\"discord_username\":\"Ada\",\"minecraft_name\":\"mc\",\"grace_until\":\"g\",\"left_guild_at\":\"l\"}");
+ IdentityStatus result = ProvinceSystemClient.getIdentityStatus(" id ");
+ assertTrue(result.ok && result.linked && result.eligible && result.inGrace);
+ assertEquals("id", result.playerUuid); assertEquals("d", result.discordUserId); assertEquals("Ada", result.discordUsername);
+ assertEquals("mc", result.minecraftName); assertEquals("g", result.graceUntil); assertEquals("l", result.leftGuildAt); assertNull(result.error);
+ assertEquals("/skins/discord/status/id", http.request().path());
+ http.reply(200, "{\"last_mint_at\":null}");
+ assertNull(ProvinceSystemClient.getCosmeticMintStatus("a b").lastMintAt);
+ assertEquals("/skins/plugin/cosmetic-mint-status?player_uuid=a+b", http.request().path());
+ }
+ @Test void simpleOperationsModerationAndOptionalFields() throws Exception {
+ assertFalse(ProvinceSystemClient.unlinkDiscord(null).ok);
+ assertFalse(ProvinceSystemClient.resetCosmeticMintCooldowns(null, null).ok);
+ assertFalse(ProvinceSystemClient.postWarning(null, "reason", null, null, null, null).ok);
+ assertFalse(ProvinceSystemClient.postWarning("id", null, null, null, null, null).ok);
+ assertFalse(ProvinceSystemClient.postBanEvent(null, null, null, null, null, null, null).ok);
+ assertTrue(ProvinceSystemClient.postBirdMail(null, null, null, null, null).ok);
+ assertFalse(ProvinceSystemClient.postBirdMail("id", "discord", null, null, null).ok);
+ http.reply(204, ""); assertTrue(ProvinceSystemClient.unlinkDiscord(" id ").ok);
+ assertEquals("{\"player_uuid\":\"id\"}", http.request().body());
+ http.reply(200, "{}"); assertTrue(ProvinceSystemClient.resetCosmeticMintCooldowns("id", " staff ").ok);
+ assertTrue(http.request().body().contains("\"staff_uuid\":\"staff\""));
+ http.reply(200, "{\"mirrored\":true}");
+ assertTrue(ProvinceSystemClient.postWarning("id", " reason ", "staff", "Ada", "disc", "mc").mirrored);
+ assertEquals("reason", JsonParser.parseString(http.request().body()).getAsJsonObject().get("reason").getAsString());
+ http.reply(200, "{}"); assertFalse(ProvinceSystemClient.postBanEvent(" BAN ", null, " ", "mc", "why", "1d", "staff").mirrored);
+ assertEquals("/skins/moderation/ban-events", http.request().path());
+ http.reply(200, "{\"mirrored\":true}");
+ assertTrue(ProvinceSystemClient.postBirdMail(" id ", "disc", " Char ", "sender", "letter").ok);
+ assertEquals("/skins/moderation/bird-mail", http.request().path());
+ assertTrue(ProvinceSystemClient.ackPluginNotices(null).ok); assertTrue(ProvinceSystemClient.ackPluginNotices(List.of()).ok);
+ http.reply(200, "{}"); assertTrue(ProvinceSystemClient.ackPluginNotices(List.of(1, 2)).ok);
+ assertEquals("{\"ids\":[1,2]}", http.request().body());
+ assertFalse(ProvinceSystemClient.putRpcPlayerMeta(null).ok); assertFalse(ProvinceSystemClient.putRpcPlayerMeta(" ").ok);
+ http.reply(200, "{}"); assertTrue(ProvinceSystemClient.putRpcPlayerMeta("{}").ok); assertEquals("PUT", http.request().method());
+ http.reply(500, "failure"); assertEquals("failure", ProvinceSystemClient.putRpcPlayerMeta("{}").error); http.request();
+ }
+ @Test void noticesParseFieldsAndCopyResults() throws Exception {
+ String valid = "{\"id\":12,\"player_uuid\":\"id\",\"type\":\"linked\",\"discord_username\":\"Ada\",\"discord_user_id\":\"d\",\"grace_until\":\"g\",\"created_at\":\"c\"}";
+ http.reply(200, "{\"notices\":[{}, {\"id\":\"bad\"}, {\"id\":1},"+valid+"]}");
+ var result = ProvinceSystemClient.listPluginNotices(); assertTrue(result.ok); assertEquals(1, result.notices.size());
+ var notice = result.notices.getFirst(); assertEquals(12, notice.id); assertEquals("id", notice.playerUuid);
+ assertEquals("linked", notice.type); assertEquals("Ada", notice.discordUsername); assertEquals("d", notice.discordUserId);
+ assertEquals("g", notice.graceUntil); assertEquals("c", notice.createdAt); http.request();
+ assertThrows(UnsupportedOperationException.class, () -> result.notices.clear());
+ var mutable = new ArrayList<>(result.notices); var copy = PluginNoticesResult.success(mutable); mutable.clear(); assertEquals(1, copy.notices.size());
+ assertTrue(PluginNoticesResult.success(null).notices.isEmpty());
+ for (String json : Arrays.asList(null, "", "{}")) assertTrue(ProvinceSystemClient.parsePluginNotices(json).isEmpty());
+ http.reply(200, "{}"); assertTrue(ProvinceSystemClient.ping().ok); http.request();
+ http.reply(503, ""); assertEquals("HTTP 503", ProvinceSystemClient.ping().error); http.request();
+ }
+ @Test void allTransportFamiliesReportHttpFailuresConfigurationAndExceptions() throws Exception {
+ List> operations = List.of(
+ () -> ProvinceSystemClient.startDiscordLink("id", "mc").error,
+ () -> ProvinceSystemClient.issueFeatureCode("id", "skin").error,
+ () -> ProvinceSystemClient.getCosmeticMintStatus("id").error,
+ () -> ProvinceSystemClient.getIdentityStatus("id").error,
+ () -> ProvinceSystemClient.listPluginNotices().error,
+ () -> ProvinceSystemClient.unlinkDiscord("id").error,
+ () -> ProvinceSystemClient.postWarning("id", "reason", null, null, null, null).error);
+ String base = Cache.apiBaseUrl;
+ for (var operation : operations) {
+ for (int status : new int[]{400, 401, 500}) {
+ http.reply(status, status == 500 ? "" : "{\"detail\":\"denied\"}");
+ String error = operation.get(); assertNotNull(error);
+ assertTrue(error.contains(status == 500 ? "HTTP 500" : status == 401 ? "Unauthorized" : "denied"), error); http.request();
+ }
+ Cache.apiBaseUrl = "invalid URL"; assertTrue(operation.get().startsWith("Could not reach API:"));
+ Cache.apiBaseUrl = null; assertTrue(operation.get().contains("not configured"));
+ Cache.apiBaseUrl = base; Cache.pluginKey = ""; assertTrue(operation.get().contains("not configured"));
+ Cache.pluginKey = "unit-test-key";
+ }
+ }
+ @Test void jsonEscapesAndRootFieldsAreDecodedCorrectly() {
+ assertEquals("line\n\t\r\b\f/A", ProvinceSystemClient.jsonString("{\"value\":\"line\\n\\t\\r\\b\\f\\/\\u0041\"}", "value"));
+ assertEquals("root", ProvinceSystemClient.jsonString("{\"nested\":{\"value\":\"wrong\"},\"value\":\"root\"}", "value"));
+ String control = "a\u0001\b\f";
+ assertEquals(control, JsonParser.parseString("\""+ProvinceSystemClient.escapeJson(control)+"\"").getAsString());
+ assertFalse(ProvinceSystemClient.escapeJson(control).contains("\u0001"));
+ }
+ @Test void scopeAndRealmAreLocaleIndependent() throws Exception {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR")); Cache.realmId = "MAIN";
+ http.reply(200, "{\"code\":\"yes\"}");
+ assertTrue(ProvinceSystemClient.issueFeatureCode("id", "SKIN").ok);
+ assertTrue(http.request().body().contains("\"main\""));
+ }
+
+ @Test void noticesWithoutAPlayerUuidAreSkipped() {
+ assertEquals("3", ProvinceSystemClient.jsonString("{\"id\":\"3\"}", "id"));
+ assertTrue(ProvinceSystemClient.parsePluginNotices("{\"notices\":[{\"id\":\"3\",\"player_uuid\":\"\"}]}").isEmpty());
+ assertTrue(ProvinceSystemClient.parsePluginNotices("{\"notices\":[{\"id\":\"3\",\"player_uuid\":null}]}").isEmpty());
+ }
+ @Test void parserHandlesEmptyMalformedAndNestedDocuments() {
+ assertNull(ProvinceSystemClient.jsonString(null,"x")); assertNull(ProvinceSystemClient.jsonString("{}",null));
+ for(String json:List.of("[]","null","{bad","{\"x\":null}","{\"x\":{}}")) assertNull(ProvinceSystemClient.jsonString(json,"x"));
+ assertEquals("42",ProvinceSystemClient.jsonString("{\"x\":42}","x"));
+ assertTrue(ProvinceSystemClient.parsePluginNotices("{\"notices\":[{}, {\"id\":1}, {\"id\":\"\"}, {\"id\":\"no\"}]}").isEmpty());
+ assertNull(ProvinceSystemClient.jsonArrayBody(null,"x")); assertNull(ProvinceSystemClient.jsonArrayBody("{}",null));
+ for(String json:List.of("{}","{\"x\"}","{\"x\":", "{\"x\":1}","{\"x\":[")) assertNull(ProvinceSystemClient.jsonArrayBody(json,"x"));
+ String array="[1],{\"text\":\"quoted\\\" ] \\\\ \",\"nested\":{\"a\":1}}";
+ assertEquals(array,ProvinceSystemClient.jsonArrayBody("{\"x\": ["+array+"]}","x"));
+ assertTrue(ProvinceSystemClient.splitJsonObjects(null).isEmpty());
+ assertEquals(List.of("{\"text\":\"quoted\\\" ] \\\\ \",\"nested\":{\"a\":1}}"),ProvinceSystemClient.splitJsonObjects(array));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java
new file mode 100644
index 0000000..7bb57f5
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/api/ProvinceSystemGatewayTest.java
@@ -0,0 +1,108 @@
+package net.tfminecraft.tfmcweb.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import net.tfminecraft.tfmcweb.Cache;
+import net.tfminecraft.tfmcweb.TestState;
+import org.junit.jupiter.api.*;
+
+class ProvinceSystemGatewayTest {
+ TestState state;
+ HttpFixture http;
+ @BeforeEach void setup() throws Exception { state = new TestState(); http = new HttpFixture(); Cache.realmId = "dev"; }
+ @AfterEach void cleanup() throws Exception { http.close(); state.close(); }
+ @Test void requestsUseMethodsHeadersBodiesAndTrimBaseSlash() throws Exception {
+ Cache.apiBaseUrl += "/";
+ http.reply(201, "one\ntwo");
+ var result = ProvinceSystemGateway.request(" post ", "/other", "{\"a\":1}");
+ assertTrue(result.ok); assertEquals(201, result.status); assertEquals("one\ntwo", result.body); assertNull(result.error);
+ var request = http.request(); assertEquals("POST", request.method()); assertEquals("/other", request.path());
+ assertEquals("unit-test-key", request.key()); assertEquals("application/json", request.contentType()); assertEquals("{\"a\":1}", request.body());
+ http.reply(200, ""); assertTrue(ProvinceSystemGateway.request(null, "/other", null).ok);
+ assertEquals("GET", http.request().method());
+ http.reply(204, ""); assertTrue(ProvinceSystemGateway.request(" ", "/other", null).ok); assertEquals("GET", http.request().method());
+ assertEquals("", ProvinceSystemGateway.GatewayResult.success(200, null).body);
+ assertEquals(0, ProvinceSystemGateway.GatewayResult.fail("bad").status);
+ }
+ @Test void uploadsAndDownloadsPreserveBytes() throws Exception {
+ byte[] bytes = new byte[]{0, 1, -1, 42};
+ http.reply(200, "ok"); assertTrue(ProvinceSystemGateway.requestBytes(null, "/file", bytes, null).ok);
+ var request = http.request(); assertEquals("PUT", request.method()); assertEquals("application/octet-stream", request.contentType());
+ http.reply(200, "ok"); assertTrue(ProvinceSystemGateway.requestBytes("POST", "/file", bytes, "image/png").ok);
+ assertEquals("image/png", http.request().contentType());
+ http.reply(200, bytes); assertArrayEquals(bytes, ProvinceSystemGateway.download("/file").data); http.request();
+ http.reply(200, ""); assertEquals("Empty file download", ProvinceSystemGateway.download("/file").error); http.request();
+ }
+ @Test void failuresKeepStatusAndBackendDetails() throws Exception {
+ for (int status : new int[]{400, 401, 500}) {
+ http.reply(status, status == 500 ? "" : "{\"detail\":\"denied\"}");
+ var result = ProvinceSystemGateway.request("GET", "/fail", null);
+ assertFalse(result.ok); assertEquals(status, result.status); assertTrue(result.error.contains(status == 500 ? "HTTP 500" : "denied")); http.request();
+ http.reply(status, "plain error"); assertTrue(ProvinceSystemGateway.download("/fail").error.contains("plain error")); http.request();
+ }
+ for (String base : Arrays.asList(null, "")) {
+ Cache.apiBaseUrl = base; assertTrue(ProvinceSystemGateway.request("GET", "/", null).error.contains("not configured"));
+ assertTrue(ProvinceSystemGateway.download("/").error.contains("not configured"));
+ }
+ Cache.apiBaseUrl = "bad URL";
+ assertTrue(ProvinceSystemGateway.request("GET", "/", null).error.startsWith("Could not reach API:"));
+ assertTrue(ProvinceSystemGateway.download("/").error.startsWith("Could not download:"));
+ }
+ @Test void injectsQueryOnlyOnAllowlistedGetsAndPreservesExplicitRealm() {
+ assertNull(ProvinceSystemGateway.injectRealmPath("GET", null));
+ assertEquals(" ", ProvinceSystemGateway.injectRealmPath("GET", " "));
+ for (String path : List.of("/characters/plugin/pending", "/skins/plugin/approved", "/drinks/plugin/pending-apply", "/characters/plugin/lore-items/pending")) {
+ assertEquals(path+"?realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path));
+ assertEquals(path+"/?realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path+"/"));
+ assertEquals(path+"?a=1&realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path+"?a=1"));
+ assertEquals(path+"?realm_id=dev", ProvinceSystemGateway.injectRealmPath("GET", path+"?"));
+ assertEquals(path+"?realm_id=main", ProvinceSystemGateway.injectRealmPath("GET", path+"?realm_id=main"));
+ assertEquals(path+"?REALM_ID", ProvinceSystemGateway.injectRealmPath("GET", path+"?REALM_ID"));
+ assertEquals(path, ProvinceSystemGateway.injectRealmPath("POST", path));
+ }
+ assertEquals("/other", ProvinceSystemGateway.injectRealmPath("GET", "/other"));
+ Cache.realmId = " dev space ";
+ assertEquals("/characters/plugin/pending?realm_id=dev+space", ProvinceSystemGateway.injectRealmPath("GET", "/characters/plugin/pending"));
+ }
+ @Test void injectsBodiesOnlyOnAllowlistedWrites() {
+ for (String method : List.of("PUT", "POST")) {
+ List paths = method.equals("PUT") ? List.of("/characters/plugin/roster", "/characters/plugin/rpc-player-meta") : List.of("/wars/declare-codes/validate", "/wars/declare-codes/redeem");
+ for (String path : paths) {
+ assertEquals("{\"realm_id\":\"dev\"}", ProvinceSystemGateway.injectRealmBody(method, path, "{}"));
+ assertEquals("{\"a\":1,\"realm_id\":\"dev\"}", ProvinceSystemGateway.injectRealmBody(method, path+"?x=1", " { \"a\":1 } "));
+ assertEquals("{\"realm_id\":\"main\"}", ProvinceSystemGateway.injectRealmBody(method, path, "{\"realm_id\":\"main\"}"));
+ for (String body : Arrays.asList(null, "", " ", "[]", "{bad", "bad}")) assertEquals(body, ProvinceSystemGateway.injectRealmBody(method, path, body));
+ }
+ }
+ assertEquals("{}", ProvinceSystemGateway.injectRealmBody("GET", "/characters/plugin/roster", "{}"));
+ assertEquals("{}", ProvinceSystemGateway.injectRealmBody("PUT", "/other", "{}"));
+ assertEquals("{}", ProvinceSystemGateway.injectRealmBody("PUT", null, "{}"));
+ }
+ @Test void patchWorksOnSupportedJavaWithoutOpeningJdkModules() throws Exception {
+ http.reply(200, "patched");
+ var result = ProvinceSystemGateway.request("PATCH", "/resource", "{}");
+ assertTrue(result.ok, result.error); assertEquals("patched", result.body);
+ assertEquals("PATCH", http.request().method());
+ }
+ @Test void backendUnicodeErrorsAreDecoded() throws Exception {
+ http.reply(400, "{\"detail\":\"bad\\n\\t\\r\\u0041\\\\\\\"\"}");
+ assertEquals("bad\n\t\rA\\\"", ProvinceSystemGateway.request("GET", "/", null).error); http.request();
+ }
+ @Test void nestedRealmDoesNotSuppressTopLevelInjection() {
+ String actual = ProvinceSystemGateway.injectRealmBody("PUT", "/characters/plugin/roster", "{\"nested\":{\"realm_id\":\"other\"}}");
+ assertEquals("dev", com.google.gson.JsonParser.parseString(actual).getAsJsonObject().get("realm_id").getAsString());
+ }
+
+ @Test void patchHandlesHttpErrorsInvalidUrlsAndInterruption() throws Exception {
+ http.reply(400,"{\"detail\":\"no\"}");
+ assertEquals("no",ProvinceSystemGateway.request("PATCH","/",null).error);http.request();
+ String base=Cache.apiBaseUrl;Cache.apiBaseUrl="invalid URL";
+ assertTrue(ProvinceSystemGateway.request("PATCH","/",null).error.startsWith("Could not reach API:"));Cache.apiBaseUrl=base;
+ try {
+ Thread.currentThread().interrupt();
+ assertEquals("Could not reach API: request interrupted",ProvinceSystemGateway.request("PATCH","/",null).error);
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally { Thread.interrupted(); }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java b/src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java
new file mode 100644
index 0000000..f40c357
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/entitlements/EntitlementsTest.java
@@ -0,0 +1,96 @@
+package net.tfminecraft.tfmcweb.entitlements;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.util.*;
+import com.google.gson.JsonParser;
+import net.tfminecraft.tfmcweb.*;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+
+class EntitlementsTest {
+ TestState state;
+ @BeforeEach void setup() throws Exception { state = new TestState(); }
+ @AfterEach void cleanup() throws Exception { state.close(); }
+ MetaGroupDefinition group(String permission, int tier, Map perks, List kinds, Boolean helmet, Boolean texture, Boolean message) {
+ return new MetaGroupDefinition(permission, tier, perks, kinds, helmet, texture, message);
+ }
+ @Test void definitionsNormalizeCopyAndFallback() {
+ var empty = group(null, -1, null, null, null, null, null);
+ assertEquals("", empty.getPermission()); assertEquals(-1, empty.getTier()); assertFalse(empty.hasIntPerk("x")); assertEquals(5,empty.getIntPerk("x",5));
+ assertFalse(empty.hasAllowArmor3dHelmet()); assertTrue(empty.getAllowArmor3dHelmet(true));
+ assertFalse(empty.hasAllowDrinkTexture()); assertTrue(empty.getAllowDrinkTexture(true));
+ assertFalse(empty.hasAllowDrinkMessage()); assertTrue(empty.getAllowDrinkMessage(true));
+ assertTrue(empty.getSkinKinds().isEmpty());
+ var perks = new HashMap<>(Map.of("x",2)); var kinds = new ArrayList<>(Arrays.asList(null," "," ITEM ","item","book"));
+ var full = group(" rank ", 3, perks, kinds, true, false, true); perks.put("x",9); kinds.clear();
+ assertEquals("rank", full.getPermission()); assertEquals(2, full.getIntPerk("x",0)); assertEquals(List.of("item","book"),full.getSkinKinds());
+ assertTrue(full.hasAllowArmor3dHelmet()); assertTrue(full.getAllowArmor3dHelmet(false));
+ assertTrue(full.hasAllowDrinkTexture()); assertFalse(full.getAllowDrinkTexture(true));
+ assertTrue(full.hasAllowDrinkMessage()); assertTrue(full.getAllowDrinkMessage(false));
+ assertThrows(UnsupportedOperationException.class, () -> full.getSkinKinds().clear());
+ assertTrue(MetaGroupDefinition.copyList(null).isEmpty()); assertTrue(MetaGroupDefinition.copyList(List.of()).isEmpty());
+ var list = new ArrayList<>(List.of(full)); var copy=MetaGroupDefinition.copyList(list); list.clear(); assertEquals(List.of(full),copy); assertTrue(MetaGroupDefinition.emptyList().isEmpty());
+ }
+ @Test void defaultAndRankLaddersResolveClampedMaximaAndInheritedSkinPerks() {
+ Cache.rpcMetaDefaults=Map.of(); Cache.skinsMetaDefaults=Map.of(); Cache.drinksMetaDefaults=Map.of();
+ Cache.rpcMetaGroups=List.of();Cache.skinsMetaGroups=List.of();Cache.drinksMetaGroups=List.of();
+ Cache.skinsMetaDefaultKinds=Arrays.asList(null," "," ITEM ");
+ var defaults=EntitlementResolver.resolve(null);
+ assertEquals(1,defaults.maxAliveCharacters);assertEquals(1,defaults.wardrobeSkinSlots);assertEquals(30720,defaults.max3dPairBytes);assertEquals(-1,defaults.skinTokenCooldownDays);assertEquals(List.of("item"),defaults.skinKinds);assertEquals(0,defaults.donatorTier);
+ Player player=mock(Player.class); when(player.hasPermission("rank.high")).thenReturn(true);when(player.hasPermission("allowed")).thenReturn(true);
+ var low=group("rank.low",1,Map.of("name-colour-stops",2,"skin-token-cooldown-days",5),List.of("book"),true,false,false);
+ var high=group("rank.high",3,Map.of("name-colour-stops",4,"max-alive-characters",8,"wardrobe-skin-slots",8,"max-3d-pair-bytes",60000),List.of("armor"),null,true,true);
+ var higher=group("rank.none",4,Map.of("skin-token-cooldown-days",0),List.of("forbidden"),false,true,true);
+ var blank=group(null,0,null,null,null,null,null);
+ Cache.rpcMetaGroups=Arrays.asList(null,blank,low,high,higher);
+ Cache.skinsMetaGroups=Arrays.asList(null,blank,low,high,higher);
+ Cache.drinksMetaGroups=Arrays.asList(null,blank,low,high,higher);
+ Cache.playerMetaSyncPermissions=Arrays.asList(null," "," allowed ","denied");
+ var meta=EntitlementResolver.resolve(player);
+ assertEquals(4,meta.nameColourStops);assertEquals(8,meta.maxAliveCharacters);assertEquals(3,meta.wardrobeSkinSlots);assertEquals(60000,meta.max3dPairBytes);
+ assertEquals(5,meta.skinTokenCooldownDays);assertEquals(List.of("item","book","armor"),meta.skinKinds);assertTrue(meta.allowArmor3dHelmet && meta.allowDrinkTexture && meta.allowDrinkMessage);
+ assertEquals(Map.of("allowed",true,"denied",false),meta.permissionFlags);assertEquals(3,meta.donatorTier);
+ Cache.skinsMetaGroups=List.of(high); assertEquals(-1,EntitlementResolver.resolve(player).skinTokenCooldownDays); assertFalse(EntitlementResolver.resolve(player).allowArmor3dHelmet);
+ Cache.drinksMetaGroups=List.of(low);when(player.hasPermission("rank.low")).thenReturn(true);assertFalse(EntitlementResolver.resolve(player).allowDrinkTexture);assertFalse(EntitlementResolver.resolve(player).allowDrinkMessage);
+ when(player.hasPermission("rank.low")).thenReturn(false);Cache.rpcMetaGroups=List.of(low);assertEquals(0,EntitlementResolver.highestRpcTier(player));
+ }
+ @Test void syncJsonCopiesValuesAndSkipsBlankKeys() {
+ UUID id=UUID.randomUUID(); Cache.realmId="dev";
+ var meta=new EntitlementResolver.ResolvedMeta(3,true,false,8,2,40000,5,List.of(" BOOK "," ","item"),true,Map.of("p\"\\",true,"q",false," ",true),-2);
+ var json=JsonParser.parseString(PlayerMetaSyncService.toJson(id,meta)).getAsJsonObject();
+ assertEquals(id.toString(),json.get("player_uuid").getAsString());assertEquals("dev",json.get("realm_id").getAsString());assertEquals(8,json.get("max_alive_characters").getAsInt());
+ assertEquals(0,json.get("donator_tier").getAsInt());assertEquals(2,json.getAsJsonArray("skin_kinds").size());assertEquals(2,json.getAsJsonObject("permission_flags").size());
+ var minimal=new EntitlementResolver.ResolvedMeta(0,false,false,null,1,1,0,List.of(),false,Map.of(),0);
+ assertFalse(JsonParser.parseString(PlayerMetaSyncService.toJson(id,minimal)).getAsJsonObject().has("max_alive_characters"));
+ }
+ @Test void syncCapturesPermissionsBeforeSchedulingAndHandlesOfflineAndFailures() {
+ UUID id=UUID.randomUUID();Player player=mock(Player.class);when(player.getUniqueId()).thenReturn(id);when(player.isOnline()).thenReturn(true);
+ TFMCWeb plugin=mock(TFMCWeb.class);var logger=mock(java.util.logging.Logger.class);when(plugin.getLogger()).thenReturn(logger);
+ BukkitScheduler scheduler=mock(BukkitScheduler.class);List async=new ArrayList<>();
+ doAnswer(call->{call.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTask(eq(plugin),any(Runnable.class));
+ doAnswer(call->{async.add(call.getArgument(1,Runnable.class));return null;}).when(scheduler).runTaskAsynchronously(eq(plugin),any(Runnable.class));
+ try(MockedStatic b=mockStatic(Bukkit.class);MockedStatic api=mockStatic(ProvinceSystemClient.class)) {
+ b.when(Bukkit::getScheduler).thenReturn(scheduler);b.when(()->Bukkit.getPlayer(id)).thenReturn(player);b.when(Bukkit::getOnlinePlayers).thenReturn(Arrays.asList(null,player));
+ TFMCWeb.plugin=null;PlayerMetaSyncService.pushForPlayer(player);PlayerMetaSyncService.pushAsync(id);PlayerMetaSyncService.pushAllOnlineAsync();assertTrue(async.isEmpty());
+ TFMCWeb.plugin=plugin;PlayerMetaSyncService.pushForPlayer(null);PlayerMetaSyncService.pushAsync(null);
+ api.when(()->ProvinceSystemClient.putRpcPlayerMeta(anyString())).thenReturn(ProvinceSystemClient.SimpleResult.success());
+ PlayerMetaSyncService.pushAsync(id);assertEquals(1,async.size());async.removeFirst().run();
+ when(player.isOnline()).thenReturn(false);PlayerMetaSyncService.pushAsync(id);assertTrue(async.isEmpty());
+ b.when(()->Bukkit.getPlayer(id)).thenReturn(null);PlayerMetaSyncService.pushAsync(id);assertTrue(async.isEmpty());
+ api.when(()->ProvinceSystemClient.putRpcPlayerMeta(anyString())).thenReturn(ProvinceSystemClient.SimpleResult.fail("unavailable"));
+ PlayerMetaSyncService.pushAllOnlineAsync();assertEquals(1,async.size());async.removeFirst().run();verify(logger).warning(contains("unavailable"));
+ }
+ }
+ @Test void syncEscapesControlCharactersAndUsesRootLocale() {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));Cache.realmId="MAIN";
+ var meta=new EntitlementResolver.ResolvedMeta(0,false,false,1,1,1,0,List.of("ITEM"),false,Map.of("a\n\u0001",true),0);
+ String payload=PlayerMetaSyncService.toJson(UUID.randomUUID(),meta);
+ assertFalse(payload.contains("\n"));assertFalse(payload.contains("\u0001"));
+ var json=JsonParser.parseString(payload).getAsJsonObject();assertEquals("item",json.getAsJsonArray("skin_kinds").get(0).getAsString());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java b/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java
index bb30502..a8928de 100644
--- a/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java
+++ b/src/test/java/net/tfminecraft/tfmcweb/gate/DiscordGateServiceTest.java
@@ -143,6 +143,49 @@ void toleratesMissingRpcAndStopsCallingDisabledRpc() {
assertEquals(Boolean.TRUE, RpcPlugin.lastRequired);
}
+ @Test
+ void fetchCachesOnlySuccessfulIdentityAndOnlineUuidUsesPlayerGate() {
+ var linked = net.tfminecraft.tfmcweb.api.ProvinceSystemClient.IdentityStatus.fromJson("{\"eligible\":true}");
+ var failed = net.tfminecraft.tfmcweb.api.ProvinceSystemClient.IdentityStatus.fail("down");
+ try (var api = mockStatic(net.tfminecraft.tfmcweb.api.ProvinceSystemClient.class)) {
+ api.when(() -> net.tfminecraft.tfmcweb.api.ProvinceSystemClient.getIdentityStatus(uuid.toString())).thenReturn(linked, failed);
+ assertTrue(gate.fetchAndCache(uuid).ok);
+ assertTrue(cache.isEligible(uuid));
+ assertFalse(gate.fetchAndCache(uuid).ok);
+ assertTrue(cache.isEligible(uuid));
+ }
+ when(rpc.isEnabled()).thenReturn(true);
+ bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player);
+ gate.applyGate(uuid, false);
+ assertEquals(Boolean.TRUE, RpcPlugin.lastRequired);
+ gate.applyGate((Player) null);
+ gate.applyGate((Player) null, false);
+ gate.applyGate((UUID) null, false);
+ gate.clearGate(null);
+ when(player.isOnline()).thenReturn(false);
+ gate.applyGate(player);
+ }
+
+ @Test
+ void unavailableOrFailingReflectiveApiIsReportedWithoutThrowing() {
+ JavaPlugin incompatible = mock(JavaPlugin.class);
+ when(incompatible.isEnabled()).thenReturn(true);
+ when(plugins.getPlugin("RPCharacters")).thenReturn(incompatible);
+ assertFalse(gate.isRpcAvailable());
+ assertFalse(gate.isRpcAvailable());
+ ThrowingRpc failing = mock(ThrowingRpc.class);
+ when(failing.isEnabled()).thenReturn(true);
+ when(plugins.getPlugin("RPCharacters")).thenReturn(failing);
+ assertTrue(gate.isRpcAvailable());
+ gate.applyGate(uuid, false);
+ assertNull(RpcPlugin.lastRequired);
+ }
+
+ public static class ThrowingRpc extends JavaPlugin {
+ public static void setDiscordGate(UUID uuid, boolean required) { throw new IllegalStateException("fixture failure"); }
+ public static void setDiscordGate(Player player, boolean required) { throw new IllegalStateException("fixture failure"); }
+ }
+
public static class RpcPlugin extends JavaPlugin {
static UUID lastUuid;
static Boolean lastRequired;
diff --git a/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java b/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java
new file mode 100644
index 0000000..08b5927
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/listeners/EssentialsBanListenerTest.java
@@ -0,0 +1,93 @@
+package net.tfminecraft.tfmcweb.listeners;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.time.Instant;
+import java.util.*;
+import java.util.logging.Logger;
+import net.ess3.api.events.BanStatusChangeEvent;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*;
+import net.tfminecraft.tfmcweb.cache.LinkCache;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.event.*;
+import org.bukkit.plugin.*;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.junit.jupiter.api.*;
+import org.mockito.*;
+
+class EssentialsBanListenerTest {
+ public static class Affected {
+ public UUID uuid;public Object base;public String name;
+ public UUID getUUID(){return uuid;}public Object getBase(){return base;}public String getName(){return name;}
+ }
+ public static class Base {public UUID getUniqueId(){return ID;}}
+ public static class Entry {
+ public Object timeout,expiration;public String reason;
+ public Object getTimeout(){return timeout;}public Object getExpiration(){return expiration;}public String getReason(){return reason;}
+ }
+ public static class Controller {public String getName(){return "Staff";}}
+ static final UUID ID=UUID.randomUUID();
+ JavaPlugin plugin;Plugin essentials;PluginManager manager;BukkitScheduler scheduler;Logger logger;LinkCache cache;
+ MockedStatic bukkit;MockedStatic api;EssentialsBanListener listener;
+ @BeforeEach void setup(){
+ plugin=mock(JavaPlugin.class);logger=mock(Logger.class);when(plugin.getLogger()).thenReturn(logger);essentials=mock(Plugin.class);when(essentials.isEnabled()).thenReturn(true);
+ manager=mock(PluginManager.class);when(manager.getPlugin("Essentials")).thenReturn(essentials);scheduler=mock(BukkitScheduler.class);
+ bukkit=mockStatic(Bukkit.class);bukkit.when(Bukkit::getPluginManager).thenReturn(manager);bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ doAnswer(c->{c.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTaskAsynchronously(eq(plugin),any(Runnable.class));
+ api=mockStatic(ProvinceSystemClient.class, call -> call.getMethod().getName().equals("jsonString") ? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));api.when(()->ProvinceSystemClient.getIdentityStatus(anyString())).thenReturn(IdentityStatus.fail("none"));
+ api.when(()->ProvinceSystemClient.postBanEvent(anyString(),anyString(),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class))).thenReturn(MirrorResult.success(true));
+ cache=new LinkCache();listener=new EssentialsBanListener(plugin,cache);
+ }
+ @AfterEach void cleanup(){api.close();bukkit.close();}
+ EventExecutor register(){assertTrue(listener.register());var executor=ArgumentCaptor.forClass(EventExecutor.class);verify(manager).registerEvent(eq(BanStatusChangeEvent.class),any(Listener.class),eq(EventPriority.MONITOR),executor.capture(),eq(plugin),eq(true));assertTrue(listener.isRegistered());return executor.getValue();}
+ BanStatusChangeEvent event(){var e=new BanStatusChangeEvent();var a=new Affected();a.uuid=ID;a.name="Ada";e.affected=a;e.value=true;return e;}
+ @Test void registrationHandlesMissingDisabledAndBrokenPluginManager() {
+ assertFalse(listener.isRegistered());when(manager.getPlugin("Essentials")).thenReturn(null);assertFalse(listener.register());
+ when(manager.getPlugin("Essentials")).thenReturn(essentials);when(essentials.isEnabled()).thenReturn(false);assertFalse(listener.register());
+ when(essentials.isEnabled()).thenReturn(true);doThrow(new IllegalStateException("bad")).when(manager).registerEvent(any(),any(),any(),any(),any(),anyBoolean());assertFalse(listener.register());
+ }
+ @Test void registrationHandlesMissingOptionalEventClass() throws Exception {
+ String name=EssentialsBanListener.class.getName();ClassLoader parent=getClass().getClassLoader();
+ ClassLoader isolated=new ClassLoader(parent){@Override protected Class> loadClass(String candidate,boolean resolve)throws ClassNotFoundException{
+ if(candidate.equals("net.ess3.api.events.BanStatusChangeEvent"))throw new ClassNotFoundException(candidate);
+ if(candidate.startsWith(name)){Class> found=findLoadedClass(candidate);if(found!=null)return found;
+ try(var bytes=parent.getResourceAsStream(candidate.replace('.','/')+".class")){byte[] data=bytes.readAllBytes();Class> cls=defineClass(candidate,data,0,data.length,EssentialsBanListener.class.getProtectionDomain());if(resolve)resolveClass(cls);return cls;}catch(java.io.IOException ex){throw new ClassNotFoundException(candidate,ex);}}
+ return super.loadClass(candidate,resolve);
+ }};
+ Class> type=isolated.loadClass(name);Object copy=type.getConstructor(JavaPlugin.class,LinkCache.class).newInstance(plugin,cache);
+ assertEquals(false,type.getMethod("register").invoke(copy));verify(logger).warning(contains("BanStatusChangeEvent missing"));
+ }
+ @Test void mirrorsBansUnbansAndIdentityFallbacks() throws Exception {
+ EventExecutor executor=register();var e=event();cache.putLinked(ID,"cached","user");e.controller=new Controller();executor.execute(null,e);
+ api.verify(()->ProvinceSystemClient.postBanEvent("ban",ID.toString(),"cached","Ada",null,"Permanent","Staff"));
+ e.value=false;executor.execute(null,e);api.verify(()->ProvinceSystemClient.postBanEvent("unban",ID.toString(),"cached","Ada",null,null,"Staff"));
+ e.value=null;e.banned=false;e.controller=new Object();((Affected)e.affected).uuid=null;((Affected)e.affected).base=new Base();cache.clear(ID);
+ var expectedIdentity0=IdentityStatus.fromJson("{\"discord_user_id\":\"fetched\"}");
+ api.when(()->ProvinceSystemClient.getIdentityStatus(ID.toString())).thenReturn(expectedIdentity0);executor.execute(null,e);assertEquals("fetched",cache.get(ID).discordUserId);
+ e.banned=null;e.affected=null;e.name="Ada";OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(ID);bukkit.when(()->Bukkit.getOfflinePlayer("Ada")).thenReturn(offline);
+ api.when(()->ProvinceSystemClient.postBanEvent(anyString(),anyString(),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class))).thenReturn(MirrorResult.success(false));executor.execute(null,e);verify(logger).info(contains("no Discord link"));
+ api.when(()->ProvinceSystemClient.postBanEvent(anyString(),anyString(),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class),nullable(String.class))).thenReturn(MirrorResult.fail("down"));executor.execute(null,e);verify(logger).warning(contains("API failed"));
+ when(offline.getUniqueId()).thenReturn(null);executor.execute(null,e);verify(logger).warning(contains("could not resolve UUID"));
+ e.name=" ";executor.execute(null,e);verify(logger).fine(contains("missing player identity"));
+ e.name=null;e.affected=new Affected();executor.execute(null,e);
+ }
+ @Test void durationsHandlePermanentPastAndDifferentTimeRepresentations() throws Exception {
+ EventExecutor executor=register();var e=event();var entry=new Entry();e.banEntry=entry;entry.reason="reason";
+ for(Object time:Arrays.asList(null,"unsupported",0L,Instant.now().minusSeconds(5),Instant.now().plusSeconds(60),Instant.now().plusSeconds(3700),Instant.now().plusSeconds(87000),Instant.now().plusSeconds(173000))){
+ entry.expiration=time;executor.execute(null,e);
+ }
+ entry.timeout=Instant.now().plusSeconds(60).toEpochMilli();executor.execute(null,e);
+ api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("1m"),isNull()),atLeastOnce());
+ api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("1h"),isNull()));
+ api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("1 day"),isNull()));
+ api.verify(()->ProvinceSystemClient.postBanEvent(eq("ban"),eq(ID.toString()),isNull(),eq("Ada"),eq("reason"),eq("2 days"),isNull()));
+ e.entry=new Object();executor.execute(null,e);
+ }
+ @Test void handlerLogsUnexpectedReflectionFailures() throws Exception {
+ EventExecutor executor=register();var e=mock(BanStatusChangeEvent.class);when(e.getValue()).thenThrow(new IllegalStateException("bad"));executor.execute(null,e);
+ verify(logger).log(eq(java.util.logging.Level.WARNING),contains("handler failed"),any(Exception.class));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java
new file mode 100644
index 0000000..5987bf8
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java
@@ -0,0 +1,128 @@
+package net.tfminecraft.tfmcweb.loaders;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.nio.file.*;
+import java.util.*;
+import net.tfminecraft.tfmcweb.*;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.TempDir;
+
+class ConfigurationTest {
+ @TempDir Path temp;
+ TestState state;
+ @BeforeEach void setup() throws Exception { state=new TestState(); }
+ @AfterEach void cleanup() throws Exception { state.close(); }
+ void load(String yaml) throws Exception {
+ Path file=temp.resolve("config.yml");Files.writeString(file,yaml);new ConfigLoader().load(file.toFile());
+ }
+ @Test void defaultsAndMissingOrInvalidFiles() throws Exception {
+ load("");assertEquals("",Cache.apiBaseUrl);assertEquals("",Cache.pluginKey);assertEquals("main",Cache.realmId);
+ assertEquals(List.of("skin","drink","profile","skin_staff"),Cache.tokenEnabledScopes);
+ assertEquals(List.of("skin","drink"),Cache.tokenCooldownSharedScopes);assertEquals(-1,Cache.tokenCooldownDefaultDays);
+ assertEquals(3,Cache.rpcMetaDefaults.get("max-alive-characters"));assertTrue(Cache.rpcMetaGroups.isEmpty());
+ Cache.apiBaseUrl="preserved";new ConfigLoader().load(temp.resolve("missing.yml").toFile());assertEquals("preserved",Cache.apiBaseUrl);
+ load("bad: [");assertEquals("preserved",Cache.apiBaseUrl);
+ load("realm:\n id: ' '\ntokens:\n enabled-scopes: []\n");assertEquals("main",Cache.realmId);assertTrue(Cache.tokenEnabledScopes.isEmpty());
+ }
+ @Test void normalizesApiTokensAndCooldownRows() throws Exception {
+ load("""
+ api:
+ base-url: ' http://localhost/// '
+ plugin-key: ' key '
+ realm:
+ id: ' DEV '
+ tokens:
+ enabled-scopes: [null, ' ', SKIN, skin, drink, profile, skin_staff, bogus]
+ token-cooldowns:
+ shared-scopes: [null, ' ', ' SKIN ']
+ defaults:
+ cooldown-days: 3
+ groups:
+ - nonsense
+ - {permission: ' '}
+ - {permission: one, cooldown-days: 5}
+ - {permission: two, cooldown-days: '7'}
+ - {permission: three, cooldown-days: bad}
+ """);
+ assertEquals("http://localhost",Cache.apiBaseUrl);assertEquals("key",Cache.pluginKey);assertEquals("dev",Cache.realmId);
+ assertEquals(4,Cache.tokenEnabledScopes.size());assertEquals(List.of("skin"),Cache.tokenCooldownSharedScopes);
+ assertEquals(3,Cache.tokenCooldownDefaultDays);assertEquals(3,Cache.tokenCooldownGroups.size());
+ assertEquals(List.of(5,7,-1),Cache.tokenCooldownGroups.stream().map(g->g.cooldownDays).toList());
+ assertFalse(Cache.isTokenScopeEnabled(null));assertFalse(Cache.isTokenScopeEnabled(" "));assertTrue(Cache.isTokenScopeEnabled(" SKIN "));assertFalse(Cache.isTokenScopeEnabled("no"));
+ Cache.tokenEnabledScopes=Arrays.asList(null," DRINK ");assertTrue(Cache.isTokenScopeEnabled("drink"));
+ assertFalse(Cache.isSharedMintScope(null));assertFalse(Cache.isSharedMintScope(" "));assertFalse(Cache.isSharedMintScope("profile"));assertTrue(Cache.isSharedMintScope("SKIN"));
+ Cache.tokenCooldownSharedScopes=Arrays.asList(null," DRINK ");assertTrue(Cache.isSharedMintScope("drink"));
+ assertEquals("",new Cache.TokenCooldownGroup(null,0).permission);assertTrue(Cache.newIntMap().isEmpty());
+ }
+ @Test void mapsAndConfigurationSectionsLoadAllLadders() {
+ YamlConfiguration config=new YamlConfiguration();
+ config.set("player-meta.sync-permissions",Arrays.asList(null," "," node ",123));
+ var row=new HashMap();row.put("permission","rank");row.put("tier","2");
+ row.put("name-colour-stops",3);row.put("max-alive-characters","bad");row.put("wardrobe-skin-slots",null);
+ row.put("max-3d-pair-bytes","40000");row.put("skin-token-cooldown-days",2);
+ row.put("skin-kinds",Arrays.asList(null," "," BOOK "));row.put("allow-armor-3d-helmet","yes");
+ row.put("allow-drink-texture",1);row.put("allow-drink-message",false);
+ var section=new YamlConfiguration();row.forEach(section::set);section.set("tier",1);
+ section.set("name-colour-stops",7);section.set("allow-armor-3d-helmet",true);section.set("allow-drink-texture",true);section.set("allow-drink-message",true);
+ for(String ladder:List.of("rpc","skins","drinks")){
+ config.set("player-meta."+ladder+".groups",Arrays.asList(null,"bad",Map.of(),row,section));
+ config.set("player-meta."+ladder+".defaults.name-colour-stops",2);
+ }
+ config.set("player-meta.skins.defaults.skin-kinds",List.of(" ITEM "));
+ config.set("player-meta.skins.defaults.allow-armor-3d-helmet",true);
+ config.set("player-meta.drinks.defaults.allow-drink-texture",true);
+ config.set("player-meta.drinks.defaults.allow-drink-message",true);
+ PlayerMetaConfigLoader.load(config);
+ assertEquals(List.of("node","123"),Cache.playerMetaSyncPermissions);
+ assertEquals(List.of(1,2),Cache.rpcMetaGroups.stream().map(g->g.getTier()).toList());
+ assertEquals(List.of("book"),Cache.skinsMetaGroups.get(1).getSkinKinds());assertTrue(Cache.skinsMetaGroups.get(1).getAllowArmor3dHelmet(false));
+ assertTrue(Cache.drinksMetaGroups.get(1).getAllowDrinkTexture(false));assertFalse(Cache.drinksMetaGroups.get(1).getAllowDrinkMessage(true));
+ assertEquals(0,Cache.rpcMetaGroups.get(1).getIntPerk("max-alive-characters",9));
+ }
+ @Test void booleanVariantsAndNonListKindsFallBackSafely() {
+ for(Object value:Arrays.asList(null,true,false,1,0,"true","yes","on","1","false","no","off","0","bad")) {
+ YamlConfiguration config=new YamlConfiguration();var row=new HashMap();row.put("permission","rank");row.put("tier","bad");
+ row.put("skin-kinds","not-list");row.put("allow-armor-3d-helmet",value);row.put("allow-drink-texture",value);row.put("allow-drink-message",value);
+ config.set("player-meta.skins.groups",List.of(row));config.set("player-meta.drinks.groups",List.of(row));PlayerMetaConfigLoader.load(config);
+ boolean expected=Arrays.asList(true,1,"true","yes","on","1").contains(value);
+ assertEquals(expected,Cache.skinsMetaGroups.getFirst().getAllowArmor3dHelmet(false));assertEquals(expected,Cache.drinksMetaGroups.getFirst().getAllowDrinkTexture(false));
+ assertTrue(Cache.skinsMetaGroups.getFirst().getSkinKinds().isEmpty());assertEquals(0,Cache.skinsMetaGroups.getFirst().getTier());
+ }
+ }
+ @Test void realmOverlaysDefaultsAndReplacesGroupLists() {
+ for(String realm:Arrays.asList(null," "," MAIN ")){
+ Cache.realmId=realm;YamlConfiguration config=new YamlConfiguration();
+ for(String ladder:List.of("rpc","skins","drinks")){
+ config.set("player-meta."+ladder+".defaults.name-colour-stops",1);
+ config.set("player-meta.by-realm.main."+ladder+".defaults.name-colour-stops",4);
+ config.set("player-meta.by-realm.main."+ladder+".groups",List.of(Map.of("permission","override")));
+ }
+ config.set("player-meta.by-realm.main.skins.defaults.skin-kinds",List.of("book"));
+ config.set("player-meta.by-realm.main.skins.defaults.allow-armor-3d-helmet",true);
+ config.set("player-meta.by-realm.main.drinks.defaults.allow-drink-texture",true);
+ config.set("player-meta.by-realm.main.drinks.defaults.allow-drink-message",true);
+ PlayerMetaConfigLoader.load(config);assertEquals(4,Cache.rpcMetaDefaults.get("name-colour-stops"));assertEquals(4,Cache.skinsMetaDefaults.get("name-colour-stops"));
+ assertEquals(4,Cache.drinksMetaDefaults.get("name-colour-stops"));assertEquals("override",Cache.rpcMetaGroups.getFirst().getPermission());
+ assertEquals(List.of("book"),Cache.skinsMetaDefaultKinds);assertTrue(Cache.skinsMetaDefaultAllowArmor3dHelmet&&Cache.drinksMetaDefaultAllowTexture&&Cache.drinksMetaDefaultAllowMessage);
+ }
+ }
+ @Test void partialRealmSkinDefaultsPreserveInheritedKinds() {
+ Cache.realmId="dev";YamlConfiguration config=new YamlConfiguration();
+ config.set("player-meta.skins.defaults.skin-kinds",List.of("item","book"));
+ config.set("player-meta.by-realm.dev.skins.defaults.name-colour-stops",3);
+ PlayerMetaConfigLoader.load(config);assertEquals(List.of("item","book"),Cache.skinsMetaDefaultKinds);
+ }
+ @Test void cooldownConfigurationSectionsAreSupported() throws Exception {
+ YamlConfiguration source=new YamlConfiguration();var row=new YamlConfiguration();row.set("permission","rank");row.set("cooldown-days",4);
+ source.set("token-cooldowns.groups",List.of(row));
+ try(var construction=mockConstruction(YamlConfiguration.class,(mock,context)->{
+ when(mock.getString(anyString(),anyString())).thenAnswer(call->source.getString(call.getArgument(0),call.getArgument(1)));
+ when(mock.getList(anyString())).thenAnswer(call->source.getList(call.getArgument(0)));
+ when(mock.getInt(anyString(),anyInt())).thenAnswer(call->source.getInt(call.getArgument(0),call.getArgument(1)));
+ })){
+ new ConfigLoader().load(temp.resolve("unused.yml").toFile());assertEquals(4,Cache.tokenCooldownGroups.getFirst().cooldownDays);
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java b/src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java
new file mode 100644
index 0000000..63fcd37
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/CommandFixture.java
@@ -0,0 +1,43 @@
+package net.tfminecraft.tfmcweb.managers;
+
+import static org.mockito.Mockito.*;
+import java.util.*;
+import java.util.logging.Logger;
+import net.tfminecraft.tfmcweb.*;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.cache.LinkCache;
+import net.tfminecraft.tfmcweb.gate.DiscordGateService;
+import org.bukkit.Bukkit;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.mockito.MockedStatic;
+
+final class CommandFixture implements AutoCloseable {
+ final TestState state=new TestState();
+ final JavaPlugin plugin=mock(JavaPlugin.class);
+ final Player player=mock(Player.class);
+ final CommandSender console=mock(CommandSender.class);
+ final UUID id=UUID.randomUUID();
+ final LinkCache cache=new LinkCache();
+ final DiscordGateService gate=mock(DiscordGateService.class);
+ final BukkitScheduler scheduler=mock(BukkitScheduler.class);
+ final Logger logger=mock(Logger.class);
+ final MockedStatic bukkit=mockStatic(Bukkit.class);
+ final MockedStatic api=mockStatic(ProvinceSystemClient.class, call -> call.getMethod().getName().equals("jsonString") ? call.callRealMethod() : org.mockito.Answers.RETURNS_DEFAULTS.answer(call));
+ final List messages=new ArrayList<>();
+ CommandFixture() throws Exception {
+ when(plugin.getLogger()).thenReturn(logger);when(player.getUniqueId()).thenReturn(id);when(player.getName()).thenReturn("Ada");
+ when(player.getPlayer()).thenReturn(player);when(player.isOnline()).thenReturn(true);when(player.hasPlayedBefore()).thenReturn(true);when(console.getName()).thenReturn("Console");
+ doAnswer(c->{messages.add(c.getArgument(0));return null;}).when(player).sendMessage(anyString());
+ doAnswer(c->{messages.add(c.getArgument(0));return null;}).when(console).sendMessage(anyString());
+ bukkit.when(Bukkit::getScheduler).thenReturn(scheduler);bukkit.when(()->Bukkit.getPlayerExact("Ada")).thenReturn(player);
+ bukkit.when(()->Bukkit.getPlayer(id)).thenReturn(player);bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player));
+ doAnswer(c->{c.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTask(eq(plugin),any(Runnable.class));
+ doAnswer(c->{c.getArgument(1,Runnable.class).run();return null;}).when(scheduler).runTaskAsynchronously(eq(plugin),any(Runnable.class));
+ }
+ void permissions(CommandSender sender,boolean allowed){when(sender.hasPermission(anyString())).thenReturn(allowed);}
+ boolean contains(String text){return messages.stream().anyMatch(s->s.contains(text));}
+ @Override public void close() throws Exception {api.close();bukkit.close();state.close();}
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java
new file mode 100644
index 0000000..e01c30f
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/NoticeAndJoinTest.java
@@ -0,0 +1,74 @@
+package net.tfminecraft.tfmcweb.managers;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.util.*;
+import net.tfminecraft.tfmcweb.TFMCWeb;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*;
+import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService;
+import net.tfminecraft.tfmcweb.listeners.PlayerJoinListener;
+import net.tfminecraft.tfmcweb.mail.BirdMailGateway;
+import org.bukkit.Bukkit;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.*;
+import org.mockito.ArgumentCaptor;
+
+class NoticeAndJoinTest {
+ CommandFixture f;
+ @BeforeEach void setup() throws Exception {f=new CommandFixture();}
+ @AfterEach void cleanup() throws Exception {f.close();}
+ PluginNotice notice(int id,String type,String uuid,String name){return new PluginNotice(id,type,uuid,name,"discord","soon","now");}
+ @Test void pollerLifecycleFailuresAndEmptyResponses() {
+ BukkitTask task=mock(BukkitTask.class);when(f.scheduler.runTaskTimerAsynchronously(eq(f.plugin),any(Runnable.class),eq(20L),eq(20L))).thenReturn(task);
+ var poller=new PluginNoticePoller(f.plugin,f.cache,f.gate);poller.stop();poller.start();poller.start();
+ ArgumentCaptor ticks=ArgumentCaptor.forClass(Runnable.class);verify(f.scheduler).runTaskTimerAsynchronously(eq(f.plugin),ticks.capture(),eq(20L),eq(20L));
+ f.api.when(ProvinceSystemClient::listPluginNotices).thenThrow(new IllegalStateException("offline"));ticks.getValue().run();verify(f.logger).log(eq(java.util.logging.Level.WARNING),contains("poll failed"),any(Exception.class));
+ f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.fail("down"));ticks.getValue().run();verify(f.logger).fine(contains("down"));
+ f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of()));ticks.getValue().run();
+ poller.stop();poller.stop();verify(task).cancel();
+ }
+ @Test void noticeDeliveryUpdatesCacheAndAcknowledgesOnlyOwnedDeliverableNotices() {
+ List ticks=new ArrayList<>();when(f.scheduler.runTaskTimerAsynchronously(eq(f.plugin),any(Runnable.class),eq(20L),eq(20L))).thenAnswer(c->{ticks.add(c.getArgument(1));return mock(BukkitTask.class);});
+ var poller=new PluginNoticePoller(f.plugin,f.cache,f.gate);poller.start();Runnable tick=ticks.getFirst();
+ f.api.when(()->ProvinceSystemClient.ackPluginNotices(anyList())).thenReturn(SimpleResult.success());
+ int id=0;
+ for(String type:Arrays.asList("link_success","guild_left_grace","guild_rejoined","grace_expired","unknown",null)){
+ f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of(notice(++id,type,f.id.toString()," AdaDiscord "))));tick.run();
+ }
+ assertFalse(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,false);assertTrue(f.contains("AdaDiscord"));
+ f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of(notice(20,"link_success",f.id.toString()," "),notice(21,"unknown","invalid",null))));
+ f.api.when(()->ProvinceSystemClient.ackPluginNotices(anyList())).thenReturn(SimpleResult.fail("down"));tick.run();assertTrue(f.contains("Discord linked successfully."));verify(f.logger).warning(contains("ack failed"));
+ f.bukkit.when(()->Bukkit.getPlayer(f.id)).thenReturn(null);
+ for(String type:List.of("link_success","guild_left_grace","guild_rejoined","grace_expired")){
+ f.api.when(ProvinceSystemClient::listPluginNotices).thenReturn(PluginNoticesResult.success(List.of(notice(++id,type,f.id.toString(),null))));tick.run();
+ }
+ verify(f.gate).applyGate(f.id,false);
+ f.api.verify(()->ProvinceSystemClient.ackPluginNotices(List.of(1)));
+ f.api.verify(()->ProvinceSystemClient.ackPluginNotices(List.of(7)),never());
+ }
+ @Test void joinFetchAppliesGateAndHandlesOfflineFailuresOrExceptions() {
+ var listener=new PlayerJoinListener(f.plugin,f.gate);var event=mock(PlayerJoinEvent.class);when(event.getPlayer()).thenReturn(f.player);
+ try(var sync=mockStatic(PlayerMetaSyncService.class)){
+ var expectedIdentity0=IdentityStatus.fromJson("{\"eligible\":true}");
+ when(f.gate.fetchAndCache(f.id)).thenReturn(expectedIdentity0);listener.onJoin(event);verify(f.gate).applyGate(f.player,true);sync.verify(()->PlayerMetaSyncService.pushForPlayer(f.player));
+ when(f.gate.fetchAndCache(f.id)).thenReturn(IdentityStatus.fail("down"));listener.onJoin(event);verify(f.gate).applyGate(f.player);verify(f.logger).fine(contains("down"));
+ when(f.player.isOnline()).thenReturn(false);listener.onJoin(event);verify(f.gate,times(1)).applyGate(f.player);
+ when(f.gate.fetchAndCache(f.id)).thenThrow(new IllegalStateException("bad"));listener.onJoin(event);verify(f.logger).log(eq(java.util.logging.Level.WARNING),contains("identity sync failed"),any(Exception.class));
+ }
+ }
+ @Test void birdGatewayValidatesPluginAndUsesCachedOrFetchedIdentity() {
+ assertFalse(BirdMailGateway.enqueueArrival(null,"Char",null,null));assertFalse(BirdMailGateway.enqueueArrival(f.id,null,null,null));assertFalse(BirdMailGateway.enqueueArrival(f.id," ",null,null));
+ TFMCWeb.plugin=null;assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));TFMCWeb plugin=mock(TFMCWeb.class);TFMCWeb.plugin=plugin;
+ assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));when(plugin.isEnabled()).thenReturn(true);when(plugin.getLinkCache()).thenReturn(f.cache);when(plugin.getLogger()).thenReturn(f.logger);
+ f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(IdentityStatus.fail("down"));assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));
+ var expectedIdentity1=IdentityStatus.fromJson("{\"discord_user_id\":\"d\"}");
+ f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(expectedIdentity1);
+ f.api.when(()->ProvinceSystemClient.postBirdMail(f.id.toString(),"d","Char",null,null)).thenReturn(MirrorResult.success(true));
+ assertTrue(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));assertEquals("d",f.cache.get(f.id).discordUserId);
+ assertTrue(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));
+ f.api.when(()->ProvinceSystemClient.postBirdMail(f.id.toString(),"d","Char",null,null)).thenReturn(MirrorResult.fail("down"));assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));
+ when(plugin.getLinkCache()).thenReturn(null);assertFalse(BirdMailGateway.enqueueArrival(f.id,"Char",null,null));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
new file mode 100644
index 0000000..6e356c4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
@@ -0,0 +1,98 @@
+package net.tfminecraft.tfmcweb.managers;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.time.Instant;
+import java.util.*;
+import net.tfminecraft.tfmcweb.*;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*;
+import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.*;
+
+class PlayerAndAdminCommandsTest {
+ CommandFixture f;
+ @BeforeEach void setup() throws Exception {f=new CommandFixture();}
+ @AfterEach void cleanup() throws Exception {f.close();}
+ void run(CommandExecutor command,org.bukkit.command.CommandSender sender,String...args){assertTrue(command.onCommand(sender,null,"cmd",args));}
+ @Test void linkShowsCodesFailuresOrExistingIdentityAndIgnoresOfflinePlayer() {
+ var cmd=new LinkDiscordCommand(f.plugin,f.cache,f.gate);run(cmd,f.console);assertTrue(f.contains("Players only"));
+ for(CodeResult result:List.of(CodeResult.fail(null),CodeResult.fail("backend"),CodeResult.alreadyLinked(" AdaDiscord "),CodeResult.alreadyLinked(" "),CodeResult.success("ABC",Instant.now().plusSeconds(4000).toString()),CodeResult.success("ABC",null))){
+ f.messages.clear();f.api.when(()->ProvinceSystemClient.startDiscordLink(f.id.toString(),"Ada")).thenReturn(result);run(cmd,f.player);
+ assertTrue(f.contains(!result.ok?result.error==null?"Link failed":"backend":result.alreadyLinked?"Already linked":"Discord link code"));
+ if(result.alreadyLinked)assertTrue(f.cache.isEligible(f.id));
+ }
+ f.messages.clear();when(f.player.isOnline()).thenReturn(false);run(cmd,f.player);assertTrue(f.messages.isEmpty());verify(f.gate,times(2)).applyGate(f.player,true);
+ }
+ @Test void unlinkUpdatesCacheAndGateOnlyWhenSuccessfulAndOnline() {
+ var cmd=new UnlinkDiscordCommand(f.plugin,f.cache,f.gate);run(cmd,f.console);assertTrue(f.contains("Players only"));
+ for(SimpleResult result:List.of(SimpleResult.fail(null),SimpleResult.fail("backend"),SimpleResult.success())){
+ f.messages.clear();f.cache.putLinked(f.id,"d","name");f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(result);run(cmd,f.player);
+ assertEquals(result.ok,!f.cache.isEligible(f.id));assertTrue(f.contains(result.ok?"Discord unlinked":result.error==null?"Unlink failed":"backend"));
+ }
+ f.messages.clear();when(f.player.isOnline()).thenReturn(false);run(cmd,f.player);assertTrue(f.messages.isEmpty());verify(f.gate).applyGate(f.player,false);
+ }
+ @Test void warningsValidateTargetsAndMirrorCachedOrFetchedIdentity() {
+ var cmd=new WarningCommand(f.plugin,f.cache);run(cmd,f.console,"Ada","reason");assertTrue(f.contains("No permission"));
+ f.permissions(f.console,true);f.permissions(f.player,true);run(cmd,f.console);assertTrue(f.contains("Usage:"));
+ run(cmd,f.console," ","reason");run(cmd,f.console,"missing","reason");assertTrue(f.contains("Unknown player"));
+ run(cmd,f.console,"Ada"," ");assertTrue(f.contains("Reason is required"));
+ f.api.when(()->ProvinceSystemClient.postWarning(anyString(),anyString(),nullable(String.class),anyString(),nullable(String.class),anyString())).thenReturn(MirrorResult.success(true));
+ f.cache.putLinked(f.id,"cached","name");run(cmd,f.player,"Ada","reason","words");
+ f.api.verify(()->ProvinceSystemClient.postWarning(f.id.toString(),"reason words",f.id.toString(),"Ada","cached","Ada"));assertTrue(f.contains("[Warning]"));
+ var expectedIdentity0=IdentityStatus.fromJson("{\"discord_user_id\":\"fetched\"}");
+ f.cache.clear(f.id);f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(expectedIdentity0);
+ f.api.when(()->ProvinceSystemClient.postWarning(anyString(),anyString(),nullable(String.class),anyString(),nullable(String.class),anyString())).thenReturn(MirrorResult.success(false));
+ run(cmd,f.console,"Ada","warning");assertEquals("fetched",f.cache.get(f.id).discordUserId);assertTrue(f.contains("DM skipped"));
+ f.cache.clear(f.id);f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(IdentityStatus.fail("down"));
+ f.api.when(()->ProvinceSystemClient.postWarning(anyString(),anyString(),nullable(String.class),anyString(),nullable(String.class),anyString())).thenReturn(MirrorResult.fail("fail"));
+ OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(f.id);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline);
+ run(cmd,f.console,"old","warning");assertTrue(f.contains("Warning store failed"));
+ }
+ @Test void webValidatesCommandsReloadsBothPluginKindsAndReportsStatus() {
+ var cmd=new WebCommand(f.plugin,f.cache,f.gate);run(cmd,f.console,"status");assertTrue(f.contains("No permission"));f.permissions(f.console,true);
+ run(cmd,f.console);run(cmd,f.console,"unknown");assertTrue(f.contains("Usage:"));assertTrue(f.contains("Unknown subcommand"));
+ Cache.apiBaseUrl="";Cache.pluginKey="";f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.success());run(cmd,f.console,"status");assertTrue(f.contains("API reachable"));
+ Cache.apiBaseUrl="local";Cache.pluginKey="key";when(f.gate.isRpcAvailable()).thenReturn(true);
+ for(String error:Arrays.asList(null,"down")){f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.fail(error));run(cmd,f.console,"status");assertTrue(f.contains(error==null?"unreachable":"down"));}
+ run(cmd,f.console,"reload");verify(f.plugin).reloadConfig();
+ TFMCWeb plugin=mock(TFMCWeb.class);run(new WebCommand(plugin,f.cache,f.gate),f.console,"reload");verify(plugin).reloadLocalConfig();
+ }
+ @Test void webLookupAndUnlinkHandleOfflineUnknownFailuresAndGating() {
+ var cmd=new WebCommand(f.plugin,f.cache,f.gate);f.permissions(f.console,true);
+ for(String action:List.of("lookup","unlink")){
+ run(cmd,f.console,action);assertTrue(f.contains("Usage:"));run(cmd,f.console,action," ");run(cmd,f.console,action,"missing");assertTrue(f.contains("Unknown player"));
+ }
+ f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(IdentityStatus.fail("down"));run(cmd,f.console,"lookup","Ada");assertTrue(f.contains("live status: down"));
+ var status=IdentityStatus.fromJson("{\"eligible\":true,\"linked\":true,\"grace_until\":\"later\",\"discord_username\":\"Discord\"}");
+ f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(status);run(cmd,f.console,"lookup","Ada");assertTrue(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,true);
+ f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(SimpleResult.fail("down"));run(cmd,f.console,"unlink","Ada");assertTrue(f.contains("Unlink failed"));
+ f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(SimpleResult.success());run(cmd,f.console,"unlink","Ada");assertFalse(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,false);
+ OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(f.id);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline);f.bukkit.when(()->Bukkit.getPlayer(f.id)).thenReturn(null);
+ run(cmd,f.console,"unlink","old");verify(f.gate).applyGate(f.id,false);run(cmd,f.console,"lookup","old");assertTrue(f.contains("Discord"));
+ }
+ @Test void webReconcileAndMetaSyncUseOnlineSnapshotAndSummarizeFailures() {
+ var cmd=new WebCommand(f.plugin,f.cache,f.gate);f.permissions(f.console,true);Player failed=mock(Player.class);UUID failedId=UUID.randomUUID();when(failed.getUniqueId()).thenReturn(failedId);
+ f.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(f.player,failed));
+ var expectedIdentity1=IdentityStatus.fromJson("{\"eligible\":true}");
+ f.api.when(()->ProvinceSystemClient.getIdentityStatus(f.id.toString())).thenReturn(expectedIdentity1);
+ f.api.when(()->ProvinceSystemClient.getIdentityStatus(failedId.toString())).thenReturn(IdentityStatus.fail("down"));
+ run(cmd,f.console,"reconcile");assertTrue(f.contains("ok=1 fail=1"));assertTrue(f.cache.isEligible(f.id));
+ try(var sync=mockStatic(PlayerMetaSyncService.class)){
+ run(cmd,f.console,"syncmeta");sync.verify(PlayerMetaSyncService::pushAllOnlineAsync);
+ run(cmd,f.console,"syncmeta"," ");run(cmd,f.console,"syncmeta","missing");assertTrue(f.contains("Unknown player"));
+ run(cmd,f.console,"syncmeta","Ada");sync.verify(()->PlayerMetaSyncService.pushForPlayer(f.player));
+ when(f.player.isOnline()).thenReturn(false);run(cmd,f.console,"syncmeta","Ada");assertTrue(f.contains("must be online"));
+ }
+ }
+ @Test void webCompletionsFilterActionsAndNames() {
+ var cmd=new WebCommand(f.plugin,f.cache,f.gate);assertTrue(cmd.onTabComplete(f.console,null,"web",new String[]{""}).isEmpty());f.permissions(f.console,true);
+ assertEquals(List.of("status","syncmeta"),cmd.onTabComplete(f.console,null,"web",new String[]{"s"}));
+ for(String action:List.of("lookup","unlink","syncmeta"))assertEquals(List.of("Ada"),cmd.onTabComplete(f.console,null,"web",new String[]{action,"A"}));
+ assertTrue(cmd.onTabComplete(f.console,null,"web",new String[]{"status",""}).isEmpty());assertTrue(cmd.onTabComplete(f.console,null,"web",new String[0]).isEmpty());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java
new file mode 100644
index 0000000..5b32409
--- /dev/null
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/TokenCommandTest.java
@@ -0,0 +1,80 @@
+package net.tfminecraft.tfmcweb.managers;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import java.time.Instant;
+import java.util.*;
+import net.tfminecraft.tfmcweb.Cache;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient;
+import net.tfminecraft.tfmcweb.api.ProvinceSystemClient.*;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.*;
+
+class TokenCommandTest {
+ CommandFixture f;TokenCommand command;
+ @BeforeEach void setup() throws Exception {f=new CommandFixture();command=new TokenCommand(f.plugin);Cache.tokenCooldownDefaultDays=0;Cache.tokenCooldownGroups=List.of();Cache.tokenEnabledScopes=List.of("skin","drink","profile","skin_staff");}
+ @AfterEach void cleanup() throws Exception {f.close();}
+ void run(CommandSender sender,String...args){assertTrue(command.onCommand(sender,null,"token",args));}
+ void expect(CommandSender sender,String message,String...args){f.messages.clear();run(sender,args);assertTrue(f.contains(message),f.messages.toString());}
+ @Test void usageAndPermissionChecks() {
+ expect(f.player,"permission");expect(f.console,"permission");expect(f.player,"permission","create","skin");
+ expect(f.console,"Players only","create","skin");expect(f.player,"Usage:","unknown");expect(f.console,"Usage:","unknown");
+ expect(f.player,"permission","resetcooldowns","Ada");expect(f.console,"permission","resetcooldowns","Ada");
+ f.permissions(f.player,true);f.permissions(f.console,true);expect(f.player,"/token create");expect(f.console,"resetcooldowns");
+ expect(f.player,"Usage:","create");
+ for(String[] args:List.of(new String[]{"create","profile","extra"},new String[]{"create","drink","extra"},new String[]{"create","skin","bad"},new String[]{"create","bad"}))expect(f.player,"Usage:",args);
+ when(f.player.hasPermission("tfmcweb.token.create")).thenReturn(false);
+ for(String kind:List.of("profile","drink","skin"))expect(f.player,"permission","create",kind);
+ when(f.player.hasPermission("tfmcweb.token.create")).thenReturn(true);when(f.player.hasPermission("tfmcweb.token.create.staff")).thenReturn(false);
+ expect(f.player,"staff skins token","create","skin","staff");
+ Cache.tokenEnabledScopes=List.of();expect(f.player,"disabled","create","skin");expect(f.player,"disabled");
+ Cache.tokenEnabledScopes=List.of("profile");expect(f.player,"not available","create","skin");
+ }
+ @Test void createsAllFourKindsAndHandlesErrorsExpiryAndDisconnect() {
+ f.permissions(f.player,true);
+ for(String kind:List.of("skin","drink","profile","skin_staff")) {
+ f.api.when(()->ProvinceSystemClient.issueFeatureCode(f.id.toString(),kind)).thenReturn(FeatureCodeResult.success("ABC",Instant.now().plusSeconds(4000).toString(),kind));
+ String[] args=kind.equals("skin_staff")?new String[]{"create","skin","staff"}:new String[]{"create",kind};
+ expect(f.player,kind.equals("skin_staff")?"curated pack":kind.equals("profile")?"/profile":kind.equals("drink")?"/drinks":"skins website",args);
+ assertTrue(f.contains("Expires in"));
+ }
+ for(String error:Arrays.asList(null,"backend failed")){
+ f.api.when(()->ProvinceSystemClient.issueFeatureCode(f.id.toString(),"profile")).thenReturn(FeatureCodeResult.fail(error));
+ expect(f.player,error==null?"Could not create token":"backend failed","create","profile");
+ }
+ f.messages.clear();when(f.player.isOnline()).thenReturn(false);run(f.player,"create","profile");assertTrue(f.messages.isEmpty());when(f.player.isOnline()).thenReturn(true);
+ Cache.tokenCooldownDefaultDays=-1;expect(f.player,"rank cannot","create","skin");
+ when(f.player.isOnline()).thenReturn(false);f.messages.clear();run(f.player,"create","drink");assertTrue(f.messages.isEmpty());
+ }
+ @Test void resetChecksTargetAndReportsBothSuccessAndFailure() {
+ for(CommandSender sender:List.of(f.player,f.console)){
+ f.permissions(sender,true);expect(sender,"Usage:","resetcooldowns");expect(sender,"Unknown player","resetcooldowns"," ");
+ expect(sender,"Unknown player","resetcooldowns","missing");
+ OfflinePlayer offline=mock(OfflinePlayer.class);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline);
+ expect(sender,"Unknown player","resetcooldowns","old");when(offline.getUniqueId()).thenReturn(f.id);expect(sender,"Unknown player","resetcooldowns","old");
+ when(offline.hasPlayedBefore()).thenReturn(true);
+ String staff=sender==f.player?f.id.toString():null;
+ f.api.when(()->ProvinceSystemClient.resetCosmeticMintCooldowns(f.id.toString(),staff)).thenReturn(SimpleResult.success());
+ expect(sender,"Reset shared","resetcooldowns","old");expect(sender,"for Ada","resetcooldowns","Ada");
+ for(String error:Arrays.asList(null,"denied")){
+ f.api.when(()->ProvinceSystemClient.resetCosmeticMintCooldowns(f.id.toString(),staff)).thenReturn(SimpleResult.fail(error));
+ expect(sender,error==null?"Could not reset":"denied","resetcooldowns","Ada");
+ }
+ }
+ when(f.player.isOnline()).thenReturn(false);f.messages.clear();run(f.player,"resetcooldowns","Ada");assertTrue(f.messages.isEmpty());
+ f.api.when(()->ProvinceSystemClient.resetCosmeticMintCooldowns(f.id.toString(),f.id.toString())).thenReturn(SimpleResult.success());run(f.player,"resetcooldowns","Ada");assertTrue(f.messages.isEmpty());
+ }
+ @Test void completionsRespectPermissionsEnabledScopesAndPrefix() {
+ assertTrue(command.onTabComplete(f.player,null,"token",new String[]{""}).isEmpty());f.permissions(f.player,true);
+ assertEquals(List.of("create","resetcooldowns"),command.onTabComplete(f.player,null,"token",new String[]{""}));
+ assertEquals(List.of("Ada"),command.onTabComplete(f.player,null,"token",new String[]{"resetcooldowns","a"}));
+ assertEquals(List.of("skin","drink","profile"),command.onTabComplete(f.player,null,"token",new String[]{"create",""}));
+ assertEquals(List.of("staff"),command.onTabComplete(f.player,null,"token",new String[]{"create","skin","s"}));
+ assertTrue(command.onTabComplete(f.player,null,"token",new String[]{"create","skin","x"}).isEmpty());
+ assertTrue(command.onTabComplete(f.player,null,"token",new String[0]).isEmpty());
+ when(f.player.hasPermission("tfmcweb.token.create")).thenReturn(false);assertEquals(List.of("skin"),command.onTabComplete(f.player,null,"token",new String[]{"create",""}));
+ Cache.tokenEnabledScopes=List.of();assertTrue(command.onTabComplete(f.player,null,"token",new String[]{"create",""}).isEmpty());
+ }
+}
From 557c0ab891665a32c57f7b559e3e83dbde590156 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Fri, 2 Oct 2026 10:14:34 +0000
Subject: [PATCH 2/2] test: address CodeRabbit fixture and lookup feedback
---
src/test/java/net/ess3/api/events/BanStatusChangeEvent.java | 4 +++-
.../tfmcweb/managers/PlayerAndAdminCommandsTest.java | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java
index f459b05..5917ef3 100644
--- a/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java
+++ b/src/test/java/net/ess3/api/events/BanStatusChangeEvent.java
@@ -5,6 +5,8 @@
/** Test-only shape for the optional Essentials reflective integration. */
public class BanStatusChangeEvent extends Event {
+ private static final HandlerList HANDLERS = new HandlerList();
+ public static HandlerList getHandlerList(){return HANDLERS;}
public Object value, banned, affected, entry, banEntry, controller, name;
public Object getValue(){return value;}
public Object isBanned(){return banned;}
@@ -13,5 +15,5 @@ public class BanStatusChangeEvent extends Event {
public Object getBanEntry(){return banEntry;}
public Object getController(){return controller;}
public Object getName(){return name;}
- @Override public HandlerList getHandlers(){return new HandlerList();}
+ @Override public HandlerList getHandlers(){return HANDLERS;}
}
diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
index 6e356c4..e045fc9 100644
--- a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
+++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java
@@ -73,7 +73,9 @@ class PlayerAndAdminCommandsTest {
f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(SimpleResult.fail("down"));run(cmd,f.console,"unlink","Ada");assertTrue(f.contains("Unlink failed"));
f.api.when(()->ProvinceSystemClient.unlinkDiscord(f.id.toString())).thenReturn(SimpleResult.success());run(cmd,f.console,"unlink","Ada");assertFalse(f.cache.isEligible(f.id));verify(f.gate).applyGate(f.player,false);
OfflinePlayer offline=mock(OfflinePlayer.class);when(offline.getUniqueId()).thenReturn(f.id);f.bukkit.when(()->Bukkit.getOfflinePlayer("old")).thenReturn(offline);f.bukkit.when(()->Bukkit.getPlayer(f.id)).thenReturn(null);
- run(cmd,f.console,"unlink","old");verify(f.gate).applyGate(f.id,false);run(cmd,f.console,"lookup","old");assertTrue(f.contains("Discord"));
+ run(cmd,f.console,"unlink","old");verify(f.gate).applyGate(f.id,false);
+ f.messages.clear();run(cmd,f.console,"lookup","old");
+ assertTrue(f.messages.stream().anyMatch(message -> message.endsWith("Discord")));
}
@Test void webReconcileAndMetaSyncUseOnlineSnapshotAndSummarizeFailures() {
var cmd=new WebCommand(f.plugin,f.cache,f.gate);f.permissions(f.console,true);Player failed=mock(Player.class);UUID failedId=UUID.randomUUID();when(failed.getUniqueId()).thenReturn(failedId);