From b31cc7a765d622d0a516d2d1b7b905ebe263e495 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:22:11 +0000 Subject: [PATCH 1/3] Apply Patreon supporter ranks from ProvinceSystem The third-party PatreonPlugin stops working when Patreon retires API v1 on 7 October, and it never counted gifted memberships. ProvinceSystem now decides each supporter's tier; this plugin applies it. One server, set with patreon.apply-ranks, polls the rank outbox and adds or removes the noble, gilded and ascended LuckPerms groups, then pushes the update to the other servers. It only removes permanent global nodes for those three groups, so staff grants and the legacy group are left alone. /patreon shows a player's supporter status and a link to connect. Co-Authored-By: Claude Opus 5.5 (1M context) --- pom.xml | 7 + .../java/net/tfminecraft/tfmcweb/Cache.java | 19 + .../java/net/tfminecraft/tfmcweb/TFMCWeb.java | 20 +- .../tfmcweb/api/PatreonClient.java | 443 ++++++++++++++++++ .../tfmcweb/loaders/ConfigLoader.java | 42 ++ .../tfmcweb/managers/PatreonCommand.java | 153 ++++++ .../tfmcweb/managers/WebCommand.java | 19 + .../patreon/LuckPermsPatreonGroupStore.java | 192 ++++++++ .../tfmcweb/patreon/PatreonGroupStore.java | 20 + .../tfmcweb/patreon/PatreonRankWriter.java | 333 +++++++++++++ .../tfmcweb/utils/ChatMessages.java | 35 ++ src/main/resources/config.yml | 12 + src/main/resources/plugin.yml | 10 +- .../tfmcweb/RuntimeUtilitiesTest.java | 9 + .../tfmcweb/TFMCWebLifecycleTest.java | 6 +- .../tfmcweb/api/PatreonClientTest.java | 146 ++++++ .../tfmcweb/loaders/ConfigurationTest.java | 37 +- .../tfmcweb/managers/PatreonCommandTest.java | 132 ++++++ .../managers/PlayerAndAdminCommandsTest.java | 4 +- .../LuckPermsPatreonGroupStoreTest.java | 259 ++++++++++ .../patreon/PatreonRankWriterTest.java | 360 ++++++++++++++ 21 files changed, 2249 insertions(+), 9 deletions(-) create mode 100644 src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java create mode 100644 src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java create mode 100644 src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java create mode 100644 src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java create mode 100644 src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java create mode 100644 src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java create mode 100644 src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java create mode 100644 src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java create mode 100644 src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java diff --git a/pom.xml b/pom.xml index 39227dd..d339ea3 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,13 @@ 26.1-R0.1-SNAPSHOT-build2096-tfmc-822e034c64c8 provided + + + net.luckperms + api + 5.4 + provided + me.plugins tlibs diff --git a/src/main/java/net/tfminecraft/tfmcweb/Cache.java b/src/main/java/net/tfminecraft/tfmcweb/Cache.java index d0e9aff..e82298a 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/Cache.java +++ b/src/main/java/net/tfminecraft/tfmcweb/Cache.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -62,8 +63,26 @@ public final class Cache { public static boolean drinksMetaDefaultAllowMessage = false; public static List drinksMetaGroups = MetaGroupDefinition.emptyList(); + /** Master switch for /patreon and the rank writer. */ + public static boolean patreonEnabled = false; + /** When true with {@link #patreonEnabled}, this server writes LuckPerms ranks. */ + public static boolean patreonApplyRanks = false; + public static int patreonPollSeconds = 60; + public static int patreonReconcileMinutes = 30; + /** Tier key → LuckPerms group. Only these groups are ever added or removed. */ + public static Map patreonGroups = Map.copyOf(patreonGroupDefaults()); + private Cache() {} + /** Mutable noble/gilded/ascended → same-name group map. */ + public static Map patreonGroupDefaults() { + Map groups = new LinkedHashMap<>(); + groups.put("noble", "noble"); + groups.put("gilded", "gilded"); + groups.put("ascended", "ascended"); + return groups; + } + /** One LP-gated cooldown row from token-cooldowns.groups. */ public static final class TokenCooldownGroup { public final String permission; diff --git a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java index 6553b0f..a44964c 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java +++ b/src/main/java/net/tfminecraft/tfmcweb/TFMCWeb.java @@ -13,11 +13,13 @@ import net.tfminecraft.tfmcweb.loaders.ConfigLoader; import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; import net.tfminecraft.tfmcweb.managers.LinkDiscordCommand; +import net.tfminecraft.tfmcweb.managers.PatreonCommand; import net.tfminecraft.tfmcweb.managers.PluginNoticePoller; import net.tfminecraft.tfmcweb.managers.TokenCommand; import net.tfminecraft.tfmcweb.managers.UnlinkDiscordCommand; import net.tfminecraft.tfmcweb.managers.WarningCommand; import net.tfminecraft.tfmcweb.managers.WebCommand; +import net.tfminecraft.tfmcweb.patreon.PatreonRankWriter; public class TFMCWeb extends JavaPlugin { @@ -27,6 +29,7 @@ public class TFMCWeb extends JavaPlugin { private final LinkCache linkCache = new LinkCache(); private DiscordGateService gateService; private PluginNoticePoller noticePoller; + private PatreonRankWriter rankWriter; @Override public void onEnable() { @@ -46,6 +49,8 @@ public void onEnable() { noticePoller = new PluginNoticePoller(this, linkCache, gateService); noticePoller.start(); + rankWriter = new PatreonRankWriter(this); + rankWriter.refresh(); if (getCommand("linkdiscord") != null) { getCommand("linkdiscord").setExecutor(new LinkDiscordCommand(this, linkCache, gateService)); @@ -60,7 +65,7 @@ public void onEnable() { getLogger().severe("Command unlinkdiscord missing from plugin.yml"); } if (getCommand("web") != null) { - WebCommand web = new WebCommand(this, linkCache, gateService); + WebCommand web = new WebCommand(this, linkCache, gateService, rankWriter); getCommand("web").setExecutor(web); getCommand("web").setTabCompleter(web); } else { @@ -78,6 +83,13 @@ public void onEnable() { } else { getLogger().severe("Command warning missing from plugin.yml"); } + if (getCommand("patreon") != null) { + PatreonCommand patreon = new PatreonCommand(this); + getCommand("patreon").setExecutor(patreon); + getCommand("patreon").setTabCompleter(patreon); + } else { + getLogger().severe("Command patreon missing from plugin.yml"); + } Bukkit.getPluginManager().registerEvents( new PlayerJoinListener(this, gateService), @@ -99,6 +111,9 @@ public void onDisable() { if (noticePoller != null) { noticePoller.stop(); } + if (rankWriter != null) { + rankWriter.stop(); + } } public LinkCache getLinkCache() { @@ -111,6 +126,9 @@ public DiscordGateService getGateService() { public void reloadLocalConfig() { configLoader.load(new File(getDataFolder(), "config.yml")); + if (rankWriter != null) { + rankWriter.refresh(); + } PlayerMetaSyncService.pushAllOnlineAsync(); } diff --git a/src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java b/src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java new file mode 100644 index 0000000..48928ab --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/api/PatreonClient.java @@ -0,0 +1,443 @@ +package net.tfminecraft.tfmcweb.api; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import net.tfminecraft.tfmcweb.api.ProvinceSystemGateway.GatewayResult; + +/** + * ProvinceSystem Patreon routes. HTTP goes through {@link ProvinceSystemGateway}. + */ +public final class PatreonClient { + + private PatreonClient() {} + + public static final class StatusResult { + public final boolean ok; + public final boolean linked; + public final String tierKey; + public final String tierName; + public final String graceUntil; + public final String patreonName; + public final String error; + + private StatusResult( + boolean ok, + boolean linked, + String tierKey, + String tierName, + String graceUntil, + String patreonName, + String error + ) { + this.ok = ok; + this.linked = linked; + this.tierKey = tierKey; + this.tierName = tierName; + this.graceUntil = graceUntil; + this.patreonName = patreonName; + this.error = error; + } + + public static StatusResult success( + boolean linked, + String tierKey, + String tierName, + String graceUntil, + String patreonName + ) { + return new StatusResult(true, linked, tierKey, tierName, graceUntil, patreonName, null); + } + + public static StatusResult fail(String error) { + return new StatusResult(false, false, null, null, null, null, error); + } + } + + public static final class LinkStartResult { + public final boolean ok; + public final String authorizeUrl; + public final String expiresAt; + public final String error; + + private LinkStartResult(boolean ok, String authorizeUrl, String expiresAt, String error) { + this.ok = ok; + this.authorizeUrl = authorizeUrl; + this.expiresAt = expiresAt; + this.error = error; + } + + public static LinkStartResult success(String authorizeUrl, String expiresAt) { + return new LinkStartResult(true, authorizeUrl, expiresAt, null); + } + + public static LinkStartResult fail(String error) { + return new LinkStartResult(false, null, null, error); + } + } + + public static final class UnlinkResult { + public final boolean ok; + public final boolean unlinked; + public final String error; + + private UnlinkResult(boolean ok, boolean unlinked, String error) { + this.ok = ok; + this.unlinked = unlinked; + this.error = error; + } + + public static UnlinkResult success(boolean unlinked) { + return new UnlinkResult(true, unlinked, null); + } + + public static UnlinkResult fail(String error) { + return new UnlinkResult(false, false, error); + } + } + + public static final class RankChange { + public final int id; + public final String playerUuid; + public final String addTier; + public final List removeTiers; + + public RankChange(int id, String playerUuid, String addTier, List removeTiers) { + this.id = id; + this.playerUuid = playerUuid; + this.addTier = addTier; + this.removeTiers = removeTiers == null ? List.of() : List.copyOf(removeTiers); + } + } + + public static final class RankChangesResult { + public final boolean ok; + public final List changes; + public final String error; + + private RankChangesResult(boolean ok, List changes, String error) { + this.ok = ok; + this.changes = changes == null ? List.of() : List.copyOf(changes); + this.error = error; + } + + public static RankChangesResult success(List changes) { + return new RankChangesResult(true, changes, null); + } + + public static RankChangesResult fail(String error) { + return new RankChangesResult(false, null, error); + } + } + + public static final class RosterMember { + public final String playerUuid; + public final String tierKey; + + public RosterMember(String playerUuid, String tierKey) { + this.playerUuid = playerUuid; + this.tierKey = tierKey; + } + } + + public static final class RosterResult { + public final boolean ok; + public final List members; + public final String error; + + private RosterResult(boolean ok, List members, String error) { + this.ok = ok; + this.members = members == null ? List.of() : List.copyOf(members); + this.error = error; + } + + public static RosterResult success(List members) { + return new RosterResult(true, members, null); + } + + public static RosterResult fail(String error) { + return new RosterResult(false, null, error); + } + } + + public static final class AckResult { + public final boolean ok; + public final String error; + + private AckResult(boolean ok, String error) { + this.ok = ok; + this.error = error; + } + + public static AckResult success() { + return new AckResult(true, null); + } + + public static AckResult fail(String error) { + return new AckResult(false, error); + } + } + + public static StatusResult status(String playerUuid) { + String uuid = trim(playerUuid); + if (uuid.isEmpty()) { + return StatusResult.fail("player_uuid is required"); + } + String path = "/patreon/status?player_uuid=" + + URLEncoder.encode(uuid, StandardCharsets.UTF_8); + GatewayResult raw = ProvinceSystemGateway.request("GET", path, null); + if (!raw.ok) { + return StatusResult.fail(raw.error); + } + JsonObject root = object(raw.body); + if (root == null) { + return StatusResult.fail("Malformed Patreon status"); + } + return StatusResult.success( + isTrue(root.get("linked")), + jsonText(root, "tier_key"), + jsonText(root, "tier_name"), + jsonText(root, "grace_until"), + jsonText(root, "patreon_name") + ); + } + + public static LinkStartResult startLink(String playerUuid, String minecraftName) { + String uuid = trim(playerUuid); + if (uuid.isEmpty()) { + return LinkStartResult.fail("player_uuid is required"); + } + JsonObject body = new JsonObject(); + body.addProperty("player_uuid", uuid); + body.addProperty("minecraft_name", minecraftName == null ? "" : minecraftName); + GatewayResult raw = ProvinceSystemGateway.request( + "POST", + "/patreon/link/start", + body.toString() + ); + if (!raw.ok) { + return LinkStartResult.fail(raw.error); + } + JsonObject root = object(raw.body); + if (root == null) { + return LinkStartResult.fail("Malformed Patreon link response"); + } + String url = jsonText(root, "authorize_url"); + if (url == null) { + return LinkStartResult.fail("API returned OK but no authorize URL."); + } + return LinkStartResult.success(url, jsonText(root, "expires_at")); + } + + public static UnlinkResult unlink(String playerUuid) { + String uuid = trim(playerUuid); + if (uuid.isEmpty()) { + return UnlinkResult.fail("player_uuid is required"); + } + JsonObject body = new JsonObject(); + body.addProperty("player_uuid", uuid); + GatewayResult raw = ProvinceSystemGateway.request( + "POST", + "/patreon/link/unlink", + body.toString() + ); + if (!raw.ok) { + return UnlinkResult.fail(raw.error); + } + JsonObject root = object(raw.body); + if (root == null) { + return UnlinkResult.fail("Malformed Patreon unlink response"); + } + return UnlinkResult.success(isTrue(root.get("unlinked"))); + } + + public static RankChangesResult listRankChanges() { + GatewayResult raw = ProvinceSystemGateway.request( + "GET", + "/patreon/plugin/rank-changes", + null + ); + if (!raw.ok) { + return RankChangesResult.fail(raw.error); + } + JsonObject root = object(raw.body); + if (root == null) { + return RankChangesResult.fail("Malformed rank-changes response"); + } + JsonElement changes = root.get("changes"); + if (changes == null || changes.isJsonNull()) { + return RankChangesResult.success(List.of()); + } + if (!changes.isJsonArray()) { + return RankChangesResult.fail("Malformed rank-changes response"); + } + List parsed = new ArrayList<>(); + for (JsonElement element : changes.getAsJsonArray()) { + RankChange change = parseChange(element); + if (change != null) { + parsed.add(change); + } + } + return RankChangesResult.success(parsed); + } + + public static AckResult ackRankChanges(List ids) { + if (ids == null || ids.isEmpty()) { + return AckResult.success(); + } + StringBuilder sb = new StringBuilder("{\"ids\":["); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(ids.get(i).intValue()); + } + sb.append("]}"); + GatewayResult raw = ProvinceSystemGateway.request( + "POST", + "/patreon/plugin/rank-changes/ack", + sb.toString() + ); + if (!raw.ok) { + return AckResult.fail(raw.error); + } + return AckResult.success(); + } + + public static RosterResult listRoster() { + GatewayResult raw = ProvinceSystemGateway.request("GET", "/patreon/plugin/roster", null); + if (!raw.ok) { + return RosterResult.fail(raw.error); + } + JsonObject root = object(raw.body); + if (root == null) { + return RosterResult.fail("Malformed roster response"); + } + JsonElement members = root.get("members"); + if (members == null || members.isJsonNull()) { + return RosterResult.success(List.of()); + } + if (!members.isJsonArray()) { + return RosterResult.fail("Malformed roster response"); + } + List parsed = new ArrayList<>(); + for (JsonElement element : members.getAsJsonArray()) { + RosterMember member = parseMember(element); + if (member != null) { + parsed.add(member); + } + } + return RosterResult.success(parsed); + } + + private static RankChange parseChange(JsonElement element) { + if (element == null || !element.isJsonObject()) { + return null; + } + JsonObject obj = element.getAsJsonObject(); + Integer id = jsonId(obj); + String uuid = jsonText(obj, "player_uuid"); + if (id == null || uuid == null) { + return null; + } + return new RankChange(id.intValue(), uuid, jsonText(obj, "add_tier"), jsonStrings(obj.get("remove_tiers"))); + } + + private static RosterMember parseMember(JsonElement element) { + if (element == null || !element.isJsonObject()) { + return null; + } + JsonObject obj = element.getAsJsonObject(); + String uuid = jsonText(obj, "player_uuid"); + if (uuid == null) { + return null; + } + return new RosterMember(uuid, jsonText(obj, "tier_key")); + } + + private static List jsonStrings(JsonElement element) { + if (element == null || element.isJsonNull() || !element.isJsonArray()) { + return List.of(); + } + List values = new ArrayList<>(); + JsonArray array = element.getAsJsonArray(); + for (JsonElement item : array) { + if (item == null || !item.isJsonPrimitive()) { + continue; + } + String text = item.getAsString().trim(); + if (!text.isEmpty()) { + values.add(text); + } + } + return values; + } + + private static Integer jsonId(JsonObject obj) { + JsonElement element = obj.get("id"); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + try { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + long value = primitive.getAsLong(); + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + return null; + } + return Integer.valueOf((int) value); + } + return Integer.valueOf(primitive.getAsString().trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String jsonText(JsonObject obj, String key) { + JsonElement element = obj.get(key); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + String text = element.getAsString().trim(); + return text.isEmpty() ? null : text; + } + + private static boolean isTrue(JsonElement element) { + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return false; + } + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isBoolean()) { + return primitive.getAsBoolean(); + } + return "true".equalsIgnoreCase(primitive.getAsString()); + } + + private static JsonObject object(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + JsonElement element = JsonParser.parseString(body); + if (!element.isJsonObject()) { + return null; + } + return element.getAsJsonObject(); + } catch (JsonParseException e) { + return null; + } + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java index 8710186..ec000b5 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/tfmcweb/loaders/ConfigLoader.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.util.List; import java.util.Locale; +import java.util.Map; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; @@ -37,6 +38,47 @@ public void load(File configFile) { loadRealmAndTokens(config); loadTokenCooldowns(config); PlayerMetaConfigLoader.load(config); + loadPatreon(config); + } + + private void loadPatreon(FileConfiguration config) { + Cache.patreonEnabled = config.getBoolean("patreon.enabled", false); + Cache.patreonApplyRanks = config.getBoolean("patreon.apply-ranks", false); + Cache.patreonPollSeconds = positiveOrDefault( + config.getInt("patreon.poll-seconds", 60), + 60 + ); + Cache.patreonReconcileMinutes = positiveOrDefault( + config.getInt("patreon.reconcile-minutes", 30), + 30 + ); + Cache.patreonGroups = Map.copyOf(readPatreonGroups( + config.getConfigurationSection("patreon.groups") + )); + } + + private static int positiveOrDefault(int value, int fallback) { + return value < 1 ? fallback : value; + } + + private static Map readPatreonGroups(ConfigurationSection section) { + Map groups = Cache.patreonGroupDefaults(); + if (section == null) { + return groups; + } + for (String rawKey : section.getKeys(false)) { + String tier = rawKey.trim().toLowerCase(Locale.ROOT); + if (!"noble".equals(tier) && !"gilded".equals(tier) && !"ascended".equals(tier)) { + continue; + } + String value = section.getString(rawKey); + if (value == null || value.isBlank()) { + groups.remove(tier); + continue; + } + groups.put(tier, value.trim()); + } + return groups; } private void loadRealmAndTokens(FileConfiguration config) { diff --git a/src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java b/src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java new file mode 100644 index 0000000..14bac34 --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/managers/PatreonCommand.java @@ -0,0 +1,153 @@ +package net.tfminecraft.tfmcweb.managers; + +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import net.tfminecraft.tfmcweb.api.PatreonClient; +import net.tfminecraft.tfmcweb.api.PatreonClient.LinkStartResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.StatusResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.UnlinkResult; +import net.tfminecraft.tfmcweb.utils.ChatMessages; +import net.tfminecraft.tfmcweb.utils.ExpiryFormat; + +/** + * /patreon — supporter status and unlink. HTTP stays off the main thread. + */ +public final class PatreonCommand implements CommandExecutor, TabCompleter { + + private final JavaPlugin plugin; + + public PatreonCommand(JavaPlugin plugin) { + this.plugin = plugin; + } + + @SuppressWarnings("deprecation") + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(ChatColor.RED + "Players only."); + return true; + } + if (args.length == 1 && "unlink".equals(args[0].toLowerCase(Locale.ROOT))) { + unlink(player); + return true; + } + if (args.length != 0) { + ChatMessages.error(player, "Usage: /patreon [unlink]"); + return true; + } + status(player); + return true; + } + + private void status(Player player) { + String uuid = player.getUniqueId().toString(); + String name = player.getName(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + StatusResult result = PatreonClient.status(uuid); + LinkStartResult link = null; + if (result.ok && !result.linked) { + link = PatreonClient.startLink(uuid, name); + } + LinkStartResult started = link; + Bukkit.getScheduler().runTask(plugin, () -> deliverStatus(player, result, started)); + }); + } + + @SuppressWarnings("deprecation") + private void deliverStatus(Player player, StatusResult result, LinkStartResult link) { + if (!player.isOnline()) { + return; + } + if (!result.ok) { + ChatMessages.error( + player, + result.error != null ? result.error : "Could not check Patreon status." + ); + return; + } + if (!result.linked) { + ChatMessages.info(player, "Patreon: " + ChatColor.YELLOW + "not linked"); + if (link == null || !link.ok) { + String error = link == null || link.error == null + ? "Could not start Patreon link." + : link.error; + ChatMessages.error(player, error); + return; + } + ChatMessages.sendOpenUrl(player, "Link your Patreon account:", link.authorizeUrl); + String expiry = ExpiryFormat.relativeLabel(link.expiresAt); + if (expiry != null) { + ChatMessages.info(player, expiry); + } + return; + } + ChatMessages.info(player, "Patreon: " + ChatColor.GREEN + "linked"); + if (result.patreonName != null) { + ChatMessages.info(player, "Patreon name: " + ChatColor.AQUA + result.patreonName); + } + ChatMessages.info(player, "Tier: " + ChatColor.AQUA + tierLabel(result)); + if (result.graceUntil != null) { + ChatMessages.info(player, "Grace until: " + ChatColor.AQUA + result.graceUntil); + } + } + + private static String tierLabel(StatusResult result) { + if (result.tierName != null && result.tierKey != null) { + return result.tierName + " (" + result.tierKey + ")"; + } + if (result.tierName != null) { + return result.tierName; + } + if (result.tierKey != null) { + return result.tierKey; + } + return "none"; + } + + private void unlink(Player player) { + String uuid = player.getUniqueId().toString(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + UnlinkResult result = PatreonClient.unlink(uuid); + Bukkit.getScheduler().runTask(plugin, () -> deliverUnlink(player, result)); + }); + } + + private void deliverUnlink(Player player, UnlinkResult result) { + if (!player.isOnline()) { + return; + } + if (!result.ok) { + ChatMessages.error(player, result.error != null ? result.error : "Unlink failed."); + return; + } + if (!result.unlinked) { + ChatMessages.info(player, "No Patreon link on this account."); + return; + } + ChatMessages.info(player, "Patreon unlinked."); + } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] args + ) { + if (args.length == 1 && "unlink".startsWith(args[0].toLowerCase(Locale.ROOT))) { + return List.of("unlink"); + } + return Collections.emptyList(); + } +} diff --git a/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java b/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java index 4d0afaa..35427b8 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java +++ b/src/main/java/net/tfminecraft/tfmcweb/managers/WebCommand.java @@ -26,6 +26,7 @@ import net.tfminecraft.tfmcweb.cache.LinkCache; import net.tfminecraft.tfmcweb.entitlements.PlayerMetaSyncService; import net.tfminecraft.tfmcweb.gate.DiscordGateService; +import net.tfminecraft.tfmcweb.patreon.PatreonRankWriter; /** * Admin /web status|reload|lookup|unlink|reconcile|syncmeta. @@ -39,11 +40,22 @@ public final class WebCommand implements CommandExecutor, TabCompleter { private final JavaPlugin plugin; private final LinkCache linkCache; private final DiscordGateService gateService; + private final PatreonRankWriter rankWriter; public WebCommand(JavaPlugin plugin, LinkCache linkCache, DiscordGateService gateService) { + this(plugin, linkCache, gateService, null); + } + + public WebCommand( + JavaPlugin plugin, + LinkCache linkCache, + DiscordGateService gateService, + PatreonRankWriter rankWriter + ) { this.plugin = plugin; this.linkCache = linkCache; this.gateService = gateService; + this.rankWriter = rankWriter; } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @@ -94,6 +106,7 @@ private boolean cmdStatus(CommandSender sender) { sender.sendMessage(ChatColor.GRAY + "link cache entries: " + linkCache.size()); sender.sendMessage(ChatColor.GRAY + "RPCharacters gate: " + (gateService.isRpcAvailable() ? "yes" : "no")); + sender.sendMessage(ChatColor.GRAY + "patreon: " + patreonStatus()); if (ping.ok) { sender.sendMessage(ChatColor.GREEN + "API reachable."); } else { @@ -116,10 +129,16 @@ private boolean cmdReload(CommandSender sender) { sender.sendMessage(ChatColor.GREEN + "[TFMCWeb] Config reloaded."); sender.sendMessage(ChatColor.GRAY + "api.base-url=" + (Cache.apiBaseUrl.isEmpty() ? "(unset)" : Cache.apiBaseUrl)); + sender.sendMessage(ChatColor.GRAY + "patreon: " + patreonStatus()); sender.sendMessage(ChatColor.GRAY + "Pushing player-meta for online players…"); return true; } + private String patreonStatus() { + boolean running = rankWriter != null && rankWriter.isRunning(); + return PatreonRankWriter.statusText(running); + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") private boolean cmdSyncMeta(CommandSender sender, String[] args) { diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java new file mode 100644 index 0000000..85af799 --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java @@ -0,0 +1,192 @@ +package net.tfminecraft.tfmcweb.patreon; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.messaging.MessagingService; +import net.luckperms.api.model.data.DataMutateResult; +import net.luckperms.api.model.data.NodeMap; +import net.luckperms.api.model.user.User; +import net.luckperms.api.model.user.UserManager; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.NodeType; +import net.luckperms.api.node.types.InheritanceNode; + +/** + * Applies supporter inheritance nodes through the LuckPerms API, including offline players. + * It never changes a user's primary group and only removes global permanent nodes. + * A failed save is reported as false so the caller can leave the outbox row unacked. + * The same request applied twice does not duplicate nodes. + */ +public final class LuckPermsPatreonGroupStore implements PatreonGroupStore { + + private final UserManager users; + private final MessagingService messaging; + private final Logger logger; + + public LuckPermsPatreonGroupStore(UserManager users, MessagingService messaging, Logger logger) { + this.users = users; + this.messaging = messaging; + this.logger = logger; + } + + public static PatreonGroupStore open() { + LuckPerms luckPerms = LuckPermsProvider.get(); + return new LuckPermsPatreonGroupStore( + luckPerms.getUserManager(), + luckPerms.getMessagingService().orElse(null), + Logger.getLogger("TFMCWeb") + ); + } + + @Override + public boolean setGroups(UUID player, String ensureGroup, Set removeGroups) { + if (player == null) { + return false; + } + String ensure = normalize(ensureGroup); + Set remove = normalizeRemove(removeGroups, ensure); + if (ensure == null && remove.isEmpty()) { + return true; + } + boolean loaded = false; + User user = null; + try { + loaded = users.isLoaded(player); + user = users.loadUser(player).join(); + if (user == null) { + return false; + } + NodeMap data = user.data(); + if (data == null) { + return false; + } + mutate(data, ensure, remove); + // Always save. A previous failed save can leave the loaded user already + // edited in memory; skipping the write would ack a change storage never got. + users.saveUser(user).join(); + pushUpdate(user, player); + return true; + } catch (RuntimeException e) { + logger.log(Level.WARNING, "[patreon] LuckPerms update failed for " + player, e); + return false; + } finally { + if (!loaded && user != null) { + try { + users.cleanupUser(user); + } catch (RuntimeException e) { + logger.log(Level.WARNING, "[patreon] LuckPerms cleanup failed for " + player, e); + } + } + } + } + + private void pushUpdate(User user, UUID player) { + if (messaging == null) { + return; + } + try { + messaging.pushUserUpdate(user); + } catch (RuntimeException e) { + logger.log(Level.WARNING, "[patreon] LuckPerms messaging update failed for " + player, e); + } + } + + private static void mutate(NodeMap data, String ensure, Set remove) { + if (ensure != null && !hasGlobal(data, ensure)) { + InheritanceNode created = InheritanceNode.builder(ensure).build(); + DataMutateResult added = data.add(created); + if (added == DataMutateResult.FAIL) { + throw new IllegalStateException("LuckPerms refused group " + ensure); + } + } + for (InheritanceNode node : inheritance(data)) { + if (node.getGroupName() == null || !node.getValue()) { + continue; + } + if (!remove.contains(node.getGroupName().toLowerCase(Locale.ROOT))) { + continue; + } + if (node.hasExpiry()) { + continue; + } + if (node.getContexts() != null && !node.getContexts().isEmpty()) { + continue; + } + data.remove(node); + } + } + + private static boolean hasGlobal(NodeMap data, String group) { + String key = group.toLowerCase(Locale.ROOT); + for (InheritanceNode node : inheritance(data)) { + if (node.getGroupName() == null || !node.getValue() || node.hasExpired()) { + continue; + } + if (!key.equals(node.getGroupName().toLowerCase(Locale.ROOT))) { + continue; + } + if (node.getContexts() != null && !node.getContexts().isEmpty()) { + continue; + } + return true; + } + return false; + } + + private static List inheritance(NodeMap data) { + List nodes = new ArrayList<>(); + Collection all = data.toCollection(); + if (all == null) { + return nodes; + } + for (Node node : all) { + if (node != null && NodeType.INHERITANCE.matches(node)) { + nodes.add(NodeType.INHERITANCE.cast(node)); + } + } + return nodes; + } + + private static boolean sameGroup(String left, String right) { + if (left == null || right == null) { + return false; + } + return left.toLowerCase(Locale.ROOT).equals(right.toLowerCase(Locale.ROOT)); + } + + private static String normalize(String group) { + if (group == null) { + return null; + } + String trimmed = group.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Set normalizeRemove(Set groups, String ensure) { + Set remove = new LinkedHashSet<>(); + if (groups == null) { + return remove; + } + for (String group : groups) { + String normalized = normalize(group); + if (normalized == null) { + continue; + } + if (sameGroup(ensure, normalized)) { + continue; + } + remove.add(normalized.toLowerCase(Locale.ROOT)); + } + return remove; + } +} diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java new file mode 100644 index 0000000..f6e2db3 --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonGroupStore.java @@ -0,0 +1,20 @@ +package net.tfminecraft.tfmcweb.patreon; + +import java.util.Set; +import java.util.UUID; + +/** + * LuckPerms mutation used by the rank writer. Only the group named by + * {@code ensureGroup} is added, and only groups in {@code removeGroups} are + * removed. Every other group, including staff grants, stays. + */ +public interface PatreonGroupStore { + + /** + * @param ensureGroup LuckPerms group to grant, or null to grant nothing + * @param removeGroups LuckPerms groups whose positive, global, permanent inheritance nodes are removed + * @return false when the user could not be loaded or saved; true when the + * stored nodes match the request, including when they already did + */ + boolean setGroups(UUID player, String ensureGroup, Set removeGroups); +} diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java new file mode 100644 index 0000000..8604d09 --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriter.java @@ -0,0 +1,333 @@ +package net.tfminecraft.tfmcweb.patreon; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.function.LongSupplier; +import java.util.logging.Level; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.api.PatreonClient; +import net.tfminecraft.tfmcweb.api.PatreonClient.AckResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.RankChange; +import net.tfminecraft.tfmcweb.api.PatreonClient.RankChangesResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.RosterMember; +import net.tfminecraft.tfmcweb.api.PatreonClient.RosterResult; + +/** + * Polls the LuckPerms outbox on the one server configured to apply ranks. + * Other servers leave this idle. Groups outside the configured mapping are never touched. + */ +public final class PatreonRankWriter { + + @FunctionalInterface + public interface StoreOpener { + PatreonGroupStore open(); + } + + private final JavaPlugin plugin; + private final StoreOpener opener; + private final LongSupplier clock; + private BukkitTask task; + private PatreonGroupStore store; + private boolean active; + private boolean loggedLuckPermsMissing; + private long nextReconcileAt; + + public PatreonRankWriter(JavaPlugin plugin) { + this(plugin, PatreonRankWriter::openLuckPerms, System::currentTimeMillis); + } + + PatreonRankWriter(JavaPlugin plugin, StoreOpener opener, LongSupplier clock) { + this.plugin = plugin; + this.opener = opener; + this.clock = clock; + } + + /** + * Loads the LuckPerms store by name so this class does not reference the API. + * Absent LuckPerms then disables only the writer. + */ + static PatreonGroupStore openLuckPerms() { + try { + Class type = Class.forName("net.tfminecraft.tfmcweb.patreon.LuckPermsPatreonGroupStore"); + return (PatreonGroupStore) type.getMethod("open").invoke(null); + } catch (ReflectiveOperationException | NoClassDefFoundError e) { + throw new IllegalStateException("LuckPerms API is not available", e); + } + } + + public void refresh() { + stop(); + if (!Cache.patreonEnabled || !Cache.patreonApplyRanks) { + return; + } + if (!luckPermsPresent()) { + if (!loggedLuckPermsMissing) { + plugin.getLogger().warning( + "LuckPerms is not installed; Patreon rank writer disabled." + ); + loggedLuckPermsMissing = true; + } + return; + } + try { + store = opener.open(); + } catch (RuntimeException e) { + plugin.getLogger().log( + Level.WARNING, + "[patreon] LuckPerms is unavailable; rank writer disabled", + e + ); + return; + } + if (store == null) { + plugin.getLogger().warning("[patreon] LuckPerms is unavailable; rank writer disabled"); + return; + } + long ticks = Math.max(1, Cache.patreonPollSeconds) * 20L; + task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::tick, ticks, ticks); + if (task == null) { + store = null; + plugin.getLogger().warning("[patreon] Could not schedule the rank writer"); + return; + } + active = true; + nextReconcileAt = clock.getAsLong(); + } + + public void stop() { + active = false; + store = null; + if (task != null) { + task.cancel(); + task = null; + } + } + + public boolean isRunning() { + return active && task != null; + } + + public String statusDetail() { + return statusText(isRunning()); + } + + public static String statusText(boolean running) { + String text = "enabled=" + Cache.patreonEnabled + + " apply-ranks=" + Cache.patreonApplyRanks + + " poll=" + Cache.patreonPollSeconds + "s" + + " reconcile=" + Cache.patreonReconcileMinutes + "m" + + " writer="; + if (running) { + return text + "on"; + } + if (Cache.patreonEnabled && Cache.patreonApplyRanks) { + return text + "off (LuckPerms unavailable)"; + } + return text + "off"; + } + + private void tick() { + PatreonGroupStore current = store; + if (!active || current == null) { + return; + } + try { + pollChanges(current); + } catch (RuntimeException e) { + plugin.getLogger().log(Level.WARNING, "[patreon] rank poll failed", e); + } + current = store; + if (!active || current == null) { + return; + } + long now = clock.getAsLong(); + if (now < nextReconcileAt) { + return; + } + try { + if (reconcile(current)) { + nextReconcileAt = now + reconcileIntervalMillis(); + } + } catch (RuntimeException e) { + plugin.getLogger().log(Level.WARNING, "[patreon] roster reconcile failed", e); + } + } + + private void pollChanges(PatreonGroupStore current) { + RankChangesResult result = PatreonClient.listRankChanges(); + if (!result.ok) { + plugin.getLogger().warning("[patreon] rank-changes: " + result.error); + return; + } + if (result.changes.isEmpty()) { + return; + } + List applied = new ArrayList<>(); + for (RankChange change : result.changes) { + try { + if (applyChange(current, change)) { + applied.add(Integer.valueOf(change.id)); + } + } catch (RuntimeException e) { + plugin.getLogger().log( + Level.WARNING, + "[patreon] rank change " + change.id + " failed", + e + ); + } + } + if (applied.isEmpty()) { + return; + } + AckResult ack = PatreonClient.ackRankChanges(applied); + if (!ack.ok) { + plugin.getLogger().warning("[patreon] rank-changes ack failed: " + ack.error); + return; + } + plugin.getLogger().info("[patreon] applied " + applied.size() + " rank change(s)"); + } + + private boolean applyChange(PatreonGroupStore current, RankChange change) { + UUID player = parseUuid(change.playerUuid); + if (player == null) { + plugin.getLogger().warning("[patreon] rank change " + change.id + " has no player UUID"); + return false; + } + String addGroup = null; + if (change.addTier != null) { + addGroup = mappedGroup(change.addTier); + if (addGroup == null) { + plugin.getLogger().warning( + "[patreon] rank change " + change.id + " has unmapped tier " + change.addTier + ); + return false; + } + } + Set remove = new LinkedHashSet<>(); + for (String tier : change.removeTiers) { + if (tier == null || tier.isBlank()) { + continue; + } + String group = mappedGroup(tier); + if (group == null) { + plugin.getLogger().warning( + "[patreon] rank change " + change.id + " skipped unmapped removal " + tier + ); + continue; + } + if (sameGroup(addGroup, group)) { + continue; + } + remove.add(group); + } + if (!current.setGroups(player, addGroup, remove)) { + plugin.getLogger().warning("[patreon] rank change " + change.id + " was not saved"); + return false; + } + return true; + } + + private boolean reconcile(PatreonGroupStore current) { + RosterResult result = PatreonClient.listRoster(); + if (!result.ok) { + plugin.getLogger().warning("[patreon] roster: " + result.error); + return false; + } + boolean saved = true; + for (RosterMember member : result.members) { + if (!reconcileMember(current, member)) { + saved = false; + } + } + if (saved) { + plugin.getLogger().info("[patreon] reconciled " + result.members.size() + " player(s)"); + } + return saved; + } + + private boolean reconcileMember(PatreonGroupStore current, RosterMember member) { + UUID player = parseUuid(member.playerUuid); + if (player == null) { + plugin.getLogger().warning("[patreon] roster skipped an invalid player UUID"); + return true; + } + String desired = null; + if (member.tierKey != null) { + desired = mappedGroup(member.tierKey); + if (desired == null) { + plugin.getLogger().warning( + "[patreon] roster skipped unmapped tier " + member.tierKey + " for " + player + ); + return true; + } + } + Set remove = new LinkedHashSet<>(); + for (String group : Cache.patreonGroups.values()) { + if (sameGroup(desired, group)) { + continue; + } + remove.add(group); + } + if (!current.setGroups(player, desired, remove)) { + plugin.getLogger().warning("[patreon] roster save failed for " + player); + return false; + } + return true; + } + + private static UUID parseUuid(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return UUID.fromString(raw.trim()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static boolean sameGroup(String left, String right) { + if (left == null || right == null) { + return false; + } + return left.toLowerCase(Locale.ROOT).equals(right.toLowerCase(Locale.ROOT)); + } + + /** @return the configured LuckPerms group, or null when the tier is not mapped */ + private static String mappedGroup(String tierKey) { + if (tierKey == null || tierKey.isBlank()) { + return null; + } + String group = Cache.patreonGroups.get(tierKey.trim().toLowerCase(Locale.ROOT)); + if (group == null || group.isBlank()) { + return null; + } + return group.trim(); + } + + private static long reconcileIntervalMillis() { + int minutes = Cache.patreonReconcileMinutes; + if (minutes < 1) { + minutes = 30; + } + return minutes * 60_000L; + } + + private static boolean luckPermsPresent() { + if (Bukkit.getPluginManager() == null) { + return false; + } + Plugin luckPerms = Bukkit.getPluginManager().getPlugin("LuckPerms"); + return luckPerms != null && luckPerms.isEnabled(); + } +} diff --git a/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java b/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java index abb123b..ea0ed5a 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java +++ b/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java @@ -1,5 +1,8 @@ package net.tfminecraft.tfmcweb.utils; +import java.net.URI; +import java.util.Locale; + import org.bukkit.entity.Player; import net.kyori.adventure.text.Component; @@ -25,6 +28,38 @@ public static void error(Player player, String message) { player.sendMessage(PREFIX + "\u00A7c" + message); } + public static void sendOpenUrl(Player player, String label, String url) { + if (player == null || url == null || url.isBlank()) { + return; + } + if (label != null && !label.isEmpty()) { + player.sendMessage(PREFIX + label); + } + if (!httpUrl(url)) { + player.sendMessage(PREFIX + url); + return; + } + Component link = Component.text(url, NamedTextColor.AQUA) + .decorate(TextDecoration.UNDERLINED) + .clickEvent(ClickEvent.openUrl(url)) + .hoverEvent(HoverEvent.showText(Component.text("Click to open"))); + player.sendMessage(link); + } + + private static boolean httpUrl(String url) { + try { + URI uri = URI.create(url); + String scheme = uri.getScheme(); + if (scheme == null) { + return false; + } + String normalized = scheme.toLowerCase(Locale.ROOT); + return "http".equals(normalized) || "https".equals(normalized); + } catch (IllegalArgumentException e) { + return false; + } + } + public static void sendCopyableCode(Player player, String intro, String code) { if (intro != null && !intro.isEmpty()) { player.sendMessage(PREFIX + intro); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 55980a1..bcc0d72 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -143,3 +143,15 @@ player-meta: name-colour-stops: 8 allow-drink-texture: true allow-drink-message: true + +# Patreon supporter ranks. LuckPerms storage is shared across servers, so +# set apply-ranks true on exactly one server. /patreon still works everywhere. +patreon: + enabled: false + apply-ranks: false + poll-seconds: 60 + reconcile-minutes: 30 + groups: + noble: noble + gilded: gilded + ascended: ascended diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0d527e6..95bc628 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ author: TFMC depend: [TLibs] # RPCharacters depends on TFMCWeb; its optional gate API is resolved at runtime. -softdepend: [Essentials] +softdepend: [Essentials, LuckPerms] commands: linkdiscord: @@ -32,6 +32,11 @@ commands: description: Warn a player in-game and mirror to Discord if linked permission: tfmcweb.warning permission-message: You do not have permission to use /warning. + patreon: + usage: /patreon [unlink] + description: Check your Patreon supporter status or unlink + permission: tfmcweb.patreon + permission-message: You do not have permission to use /patreon. permissions: tfmcweb.linkdiscord: @@ -55,3 +60,6 @@ permissions: tfmcweb.warning: description: Issue in-game warnings via /warning default: false + tfmcweb.patreon: + description: Check and unlink your Patreon supporter status + default: true diff --git a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java index 231808c..541eed6 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java @@ -48,6 +48,15 @@ class RuntimeUtilitiesTest { 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)); + ChatMessages.sendOpenUrl(null,"label","https://example.test");ChatMessages.sendOpenUrl(player,null," ");ChatMessages.sendOpenUrl(player,""," "); + ChatMessages.sendOpenUrl(player,null,"https://example.test/link");ChatMessages.sendOpenUrl(player,"","https://example.test/link"); + ChatMessages.sendOpenUrl(player,"Link your Patreon account:","https://example.test/link"); + verify(player).sendMessage(ChatMessages.PREFIX+"Link your Patreon account:"); + ArgumentCaptor link=ArgumentCaptor.forClass(Component.class);verify(player,atLeastOnce()).sendMessage(link.capture()); + assertTrue(link.getAllValues().stream().anyMatch(component->ClickEvent.openUrl("https://example.test/link").equals(component.clickEvent()))); + ChatMessages.sendOpenUrl(player,"broken","not a url");verify(player).sendMessage(ChatMessages.PREFIX+"not a url"); + ChatMessages.sendOpenUrl(player,"plain","not-a-url");verify(player).sendMessage(ChatMessages.PREFIX+"not-a-url"); + ChatMessages.sendOpenUrl(player,"mail","mailto:a@b.test");verify(player).sendMessage(ChatMessages.PREFIX+"mailto:a@b.test"); } @Test void cooldownPermissionOrderAndStatuses() { Player player=mock(Player.class);UUID id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id); diff --git a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java index 4a762b8..4349b98 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/TFMCWebLifecycleTest.java @@ -22,8 +22,8 @@ class TFMCWebLifecycleTest { @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()); + for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning","patreon"})assertNotNull(plugin.getCommand(name).getExecutor()); + assertNotNull(plugin.getCommand("web").getTabCompleter());assertNotNull(plugin.getCommand("token").getTabCompleter());assertNotNull(plugin.getCommand("patreon").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(); @@ -32,7 +32,7 @@ class TFMCWebLifecycleTest { @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); + for(String name:new String[]{"linkdiscord","unlinkdiscord","web","token","warning","patreon"})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(); diff --git a/src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java b/src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java new file mode 100644 index 0000000..bb68f5b --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/api/PatreonClientTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.tfmcweb.api; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonParser; +import java.util.ArrayList; +import java.util.List; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.TestState; +import net.tfminecraft.tfmcweb.api.PatreonClient.AckResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.LinkStartResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.RankChange; +import net.tfminecraft.tfmcweb.api.PatreonClient.RankChangesResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.RosterResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.StatusResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.UnlinkResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PatreonClientTest { + 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 statusLinkAndUnlinkUseTheGateway() throws Exception { + assertEquals("player_uuid is required", PatreonClient.status(null).error); + assertEquals("player_uuid is required", PatreonClient.status(" ").error); + assertEquals("player_uuid is required", PatreonClient.startLink(null, "Ada").error); + assertEquals("player_uuid is required", PatreonClient.unlink(" ").error); + http.reply(200, "{\"linked\":true,\"tier_key\":\"ascended\",\"tier_name\":\"Ascended\",\"grace_until\":\"2026-10-10T00:00:00Z\",\"patreon_name\":\"Ada\",\"method\":\"oauth\"}"); + StatusResult status = PatreonClient.status(" id "); + assertTrue(status.ok && status.linked); + assertEquals("ascended", status.tierKey); assertEquals("Ascended", status.tierName); + assertEquals("2026-10-10T00:00:00Z", status.graceUntil); assertEquals("Ada", status.patreonName); + assertEquals("/patreon/status?player_uuid=id", http.request().path()); + http.reply(200, "{\"linked\":false,\"tier_key\":null,\"tier_name\":\"\",\"grace_until\":null,\"patreon_name\":null}"); + status = PatreonClient.status("id"); + assertTrue(status.ok); assertFalse(status.linked); + assertNull(status.tierKey); assertNull(status.tierName); assertNull(status.graceUntil); assertNull(status.patreonName); + http.request(); + http.reply(200, "{\"linked\":\"TRUE\",\"tier_key\":1}"); + status = PatreonClient.status("id"); + assertTrue(status.linked); assertEquals("1", status.tierKey); http.request(); + for (String body : List.of("{\"linked\":false}", "{\"linked\":null}", "{\"linked\":{\"x\":1}}", "{\"linked\":\"no\"}")) { + http.reply(200, body); + assertFalse(PatreonClient.status("id").linked); + http.request(); + } + http.reply(200, "[]"); assertEquals("Malformed Patreon status", PatreonClient.status("id").error); http.request(); + http.reply(200, ""); assertEquals("Malformed Patreon status", PatreonClient.status("id").error); http.request(); + http.reply(200, "{bad"); assertEquals("Malformed Patreon status", PatreonClient.status("id").error); http.request(); + http.reply(401, "{\"detail\":\"denied\"}"); + assertTrue(PatreonClient.status("id").error.contains("denied")); http.request(); + + http.reply(200, "{\"authorize_url\":\"https://www.patreon.com/oauth2/authorize?x=1\",\"expires_at\":\"soon\"}"); + LinkStartResult link = PatreonClient.startLink("id", null); + assertTrue(link.ok); assertEquals("https://www.patreon.com/oauth2/authorize?x=1", link.authorizeUrl); assertEquals("soon", link.expiresAt); + var body = JsonParser.parseString(http.request().body()).getAsJsonObject(); + assertEquals("id", body.get("player_uuid").getAsString()); assertEquals("", body.get("minecraft_name").getAsString()); + http.reply(200, "{\"authorize_url\":\" https://example.test/link \",\"expires_at\":\"\"}"); + link = PatreonClient.startLink("id", "Ada"); + assertEquals("https://example.test/link", link.authorizeUrl); assertNull(link.expiresAt); + assertEquals("Ada", JsonParser.parseString(http.request().body()).getAsJsonObject().get("minecraft_name").getAsString()); + http.reply(200, "{}"); assertEquals("API returned OK but no authorize URL.", PatreonClient.startLink("id", "Ada").error); http.request(); + http.reply(200, "nope"); assertEquals("Malformed Patreon link response", PatreonClient.startLink("id", "Ada").error); http.request(); + http.reply(503, "{\"detail\":\"patreon_disabled\"}"); + assertEquals("patreon_disabled", PatreonClient.startLink("id", "Ada").error); http.request(); + + http.reply(200, "{\"unlinked\":true}"); + UnlinkResult unlink = PatreonClient.unlink(" id "); + assertTrue(unlink.ok && unlink.unlinked); + var unlinkRequest = http.request(); + assertEquals("{\"player_uuid\":\"id\"}", unlinkRequest.body()); + assertEquals("/patreon/link/unlink", unlinkRequest.path()); + http.reply(200, "{\"unlinked\":false}"); + unlink = PatreonClient.unlink("id"); + assertTrue(unlink.ok); assertFalse(unlink.unlinked); http.request(); + http.reply(200, "{\"unlinked\":\"false\"}"); assertFalse(PatreonClient.unlink("id").unlinked); http.request(); + http.reply(200, "[]"); assertEquals("Malformed Patreon unlink response", PatreonClient.unlink("id").error); http.request(); + http.reply(400, "{\"detail\":\"nope\"}"); assertEquals("nope", PatreonClient.unlink("id").error); http.request(); + Cache.pluginKey = ""; + assertTrue(PatreonClient.status("id").error.contains("not configured")); + assertTrue(PatreonClient.startLink("id", "Ada").error.contains("not configured")); + assertTrue(PatreonClient.unlink("id").error.contains("not configured")); + } + + @Test void rankChangesRosterAndAckParseTheOutbox() throws Exception { + assertTrue(PatreonClient.ackRankChanges(null).ok); + assertTrue(PatreonClient.ackRankChanges(List.of()).ok); + http.reply(200, "{}"); + assertTrue(PatreonClient.ackRankChanges(List.of(7, 8)).ok); + var ack = http.request(); + assertEquals("POST", ack.method()); + assertEquals("/patreon/plugin/rank-changes/ack", ack.path()); + assertEquals("{\"ids\":[7,8]}", ack.body()); + assertEquals("unit-test-key", ack.key()); + http.reply(500, ""); assertEquals("HTTP 500", PatreonClient.ackRankChanges(List.of(1)).error); http.request(); + + String change = "{\"id\":7,\"player_uuid\":\"uuid-1\",\"add_tier\":\"gilded\",\"remove_tiers\":[\"noble\",\" \",\"\",null,{\"x\":1},1]}"; + http.reply(200, "{\"changes\":[" + change + ",null,\"bad\",{\"id\":\"bad\"},{\"id\":1},{\"player_uuid\":\"x\"},{\"id\":\" 12 \",\"player_uuid\":\"uuid-2\",\"add_tier\":\"\",\"remove_tiers\":null},{\"id\":false,\"player_uuid\":\"uuid-3\"},{\"id\":3000000000,\"player_uuid\":\"uuid-4\"},{\"id\":{\"n\":1},\"player_uuid\":\"uuid-5\"}]}"); + RankChangesResult changes = PatreonClient.listRankChanges(); + assertTrue(changes.ok); assertEquals(2, changes.changes.size()); + RankChange first = changes.changes.getFirst(); + assertEquals(7, first.id); assertEquals("uuid-1", first.playerUuid); assertEquals("gilded", first.addTier); + assertEquals(List.of("noble", "1"), first.removeTiers); + assertThrows(UnsupportedOperationException.class, () -> changes.changes.clear()); + RankChange second = changes.changes.get(1); + assertEquals(12, second.id); assertNull(second.addTier); assertTrue(second.removeTiers.isEmpty()); + assertEquals("GET", http.request().method()); + List tiers = new ArrayList<>(); + tiers.add("noble"); + RankChange copied = new RankChange(1, "u", "noble", tiers); + tiers.clear(); + assertEquals(List.of("noble"), copied.removeTiers); + assertTrue(new RankChange(3, "u", null, null).removeTiers.isEmpty()); + assertTrue(RankChangesResult.success(null).changes.isEmpty()); + assertTrue(RankChangesResult.fail("x").changes.isEmpty()); + http.reply(200, "{}"); assertTrue(PatreonClient.listRankChanges().changes.isEmpty()); http.request(); + http.reply(200, "{\"changes\":null}"); assertTrue(PatreonClient.listRankChanges().changes.isEmpty()); http.request(); + http.reply(200, "{\"changes\":{}}"); assertEquals("Malformed rank-changes response", PatreonClient.listRankChanges().error); http.request(); + http.reply(200, "nope"); assertEquals("Malformed rank-changes response", PatreonClient.listRankChanges().error); http.request(); + http.reply(401, "{\"detail\":\"denied\"}"); assertTrue(PatreonClient.listRankChanges().error.contains("Unauthorized")); http.request(); + + http.reply(200, "{\"tier_keys\":[\"noble\"],\"members\":[{\"player_uuid\":\"uuid-1\",\"tier_key\":\"ascended\"},{\"player_uuid\":\"uuid-2\",\"tier_key\":null},{\"player_uuid\":\"uuid-3\",\"tier_key\":\"\"},null,\"x\",{\"tier_key\":\"noble\"}]}"); + RosterResult roster = PatreonClient.listRoster(); + assertTrue(roster.ok); assertEquals(3, roster.members.size()); + assertEquals("ascended", roster.members.getFirst().tierKey); assertNull(roster.members.get(1).tierKey); assertNull(roster.members.get(2).tierKey); + assertEquals("/patreon/plugin/roster", http.request().path()); + assertThrows(UnsupportedOperationException.class, () -> roster.members.clear()); + assertTrue(RosterResult.success(null).members.isEmpty()); + http.reply(200, "{}"); assertTrue(PatreonClient.listRoster().members.isEmpty()); http.request(); + http.reply(200, "{\"members\":null}"); assertTrue(PatreonClient.listRoster().members.isEmpty()); http.request(); + http.reply(200, "{\"members\":{}}"); assertEquals("Malformed roster response", PatreonClient.listRoster().error); http.request(); + http.reply(200, ""); assertEquals("Malformed roster response", PatreonClient.listRoster().error); http.request(); + http.reply(400, "{\"detail\":\"nope\"}"); assertEquals("nope", PatreonClient.listRoster().error); http.request(); + Cache.apiBaseUrl = null; + assertTrue(PatreonClient.listRankChanges().error.contains("not configured")); + assertTrue(PatreonClient.listRoster().error.contains("not configured")); + assertTrue(PatreonClient.ackRankChanges(List.of(1)).error.contains("not configured")); + AckResult failed = AckResult.fail(null); + assertFalse(failed.ok); assertNull(failed.error); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java index 5987bf8..fab7b15 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/loaders/ConfigurationTest.java @@ -19,11 +19,14 @@ void load(String yaml) throws Exception { } @Test void defaultsAndMissingOrInvalidFiles() throws Exception { load("");assertEquals("",Cache.apiBaseUrl);assertEquals("",Cache.pluginKey);assertEquals("main",Cache.realmId); + assertFalse(Cache.patreonEnabled);assertFalse(Cache.patreonApplyRanks);assertEquals(60,Cache.patreonPollSeconds); + assertEquals(30,Cache.patreonReconcileMinutes);assertEquals(Map.of("noble","noble","gilded","gilded","ascended","ascended"),Cache.patreonGroups); 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); + Cache.apiBaseUrl="preserved";Cache.patreonEnabled=true;new ConfigLoader().load(temp.resolve("missing.yml").toFile()); + assertEquals("preserved",Cache.apiBaseUrl);assertTrue(Cache.patreonEnabled); + load("bad: [");assertEquals("preserved",Cache.apiBaseUrl);assertTrue(Cache.patreonEnabled); load("realm:\n id: ' '\ntokens:\n enabled-scopes: []\n");assertEquals("main",Cache.realmId);assertTrue(Cache.tokenEnabledScopes.isEmpty()); } @Test void normalizesApiTokensAndCooldownRows() throws Exception { @@ -114,6 +117,36 @@ void load(String yaml) throws Exception { 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 patreonGroupsIntervalsAndLocale() throws Exception { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + load(""" + patreon: + enabled: true + apply-ranks: true + poll-seconds: 0 + reconcile-minutes: -4 + groups: + NOBLE: "" + gilded: " " + ascended: " High " + legacy: legacy + VIP: vip + """); + assertTrue(Cache.patreonEnabled && Cache.patreonApplyRanks); + assertEquals(60, Cache.patreonPollSeconds); assertEquals(30, Cache.patreonReconcileMinutes); + assertEquals(Map.of("ascended", "High"), Cache.patreonGroups); + load(""" + patreon: + poll-seconds: 15 + reconcile-minutes: 2 + groups: + noble: " donator " + """); + assertEquals(15, Cache.patreonPollSeconds); assertEquals(2, Cache.patreonReconcileMinutes); + assertEquals("donator", Cache.patreonGroups.get("noble")); + assertEquals("gilded", Cache.patreonGroups.get("gilded")); + assertEquals("ascended", Cache.patreonGroups.get("ascended")); + } @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)); diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java new file mode 100644 index 0000000..0bc3059 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PatreonCommandTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.tfmcweb.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.tfminecraft.tfmcweb.api.PatreonClient; +import net.tfminecraft.tfmcweb.api.PatreonClient.LinkStartResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.StatusResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.UnlinkResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class PatreonCommandTest { + CommandFixture f; + MockedStatic api; + PatreonCommand command; + + @BeforeEach void setup() throws Exception { + f = new CommandFixture(); + api = mockStatic(PatreonClient.class); + command = new PatreonCommand(f.plugin); + } + + @AfterEach void cleanup() throws Exception { + api.close(); + f.close(); + } + + void run(org.bukkit.command.CommandSender sender, String... args) { + assertTrue(command.onCommand(sender, null, "patreon", args)); + } + + @Test void playersCanReadStatusLinkAndUnlink() { + run(f.console); + assertTrue(f.contains("Players only")); + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.fail(null)); + run(f.player); + assertTrue(f.contains("Could not check Patreon status.")); + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.fail("down")); + run(f.player); + assertTrue(f.contains("down")); + api.verify(() -> PatreonClient.startLink(anyString(), anyString()), never()); + + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn( + StatusResult.success(true, "ascended", "Ascended", "2026-10-10T00:00:00Z", "Ada Patron") + ); + run(f.player); + assertTrue(f.contains("linked")); + assertTrue(f.contains("Ada Patron")); + assertTrue(f.contains("Ascended (ascended)")); + assertTrue(f.contains("Grace until:")); + assertTrue(f.contains("2026-10-10T00:00:00Z")); + + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(true, "noble", null, null, null)); + run(f.player); + assertTrue(f.contains("noble")); + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(true, null, "Knight", null, null)); + run(f.player); + assertTrue(f.contains("Knight")); + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(true, null, null, null, null)); + run(f.player); + assertTrue(f.contains("Tier:")); + assertTrue(f.contains("none")); + + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(false, null, null, null, null)); + api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(LinkStartResult.fail(null)); + run(f.player); + assertTrue(f.contains("not linked")); + assertTrue(f.contains("Could not start Patreon link.")); + api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(LinkStartResult.fail("denied")); + run(f.player); + assertTrue(f.contains("denied")); + + String url = "https://www.patreon.com/oauth2/authorize?x=1"; + api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn( + LinkStartResult.success(url, Instant.now().plusSeconds(3700).toString()) + ); + run(f.player); + assertTrue(f.contains("Link your Patreon account:")); + assertTrue(f.contains("Expires in ~")); + ArgumentCaptor link = ArgumentCaptor.forClass(Component.class); + verify(f.player, atLeastOnce()).sendMessage(link.capture()); + assertTrue(link.getAllValues().stream().anyMatch(component -> ClickEvent.openUrl(url).equals(component.clickEvent()))); + + api.when(() -> PatreonClient.startLink(f.id.toString(), "Ada")).thenReturn(LinkStartResult.success(url, null)); + f.messages.clear(); + run(f.player); + assertTrue(f.messages.stream().noneMatch(message -> message.contains("Expires"))); + + api.when(() -> PatreonClient.status(f.id.toString())).thenReturn(StatusResult.success(false, null, null, null, null)); + when(f.player.isOnline()).thenReturn(false); + f.messages.clear(); + run(f.player); + assertTrue(f.messages.isEmpty()); + when(f.player.isOnline()).thenReturn(true); + + run(f.player, "nope"); + assertTrue(f.contains("Usage:")); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.fail(null)); + run(f.player, "UNLINK"); + assertTrue(f.contains("Unlink failed.")); + api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.fail("down")); + run(f.player, "unlink"); + assertTrue(f.contains("down")); + api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.success(false)); + run(f.player, "unlink"); + assertTrue(f.contains("No Patreon link")); + api.when(() -> PatreonClient.unlink(f.id.toString())).thenReturn(UnlinkResult.success(true)); + run(f.player, "unlink"); + assertTrue(f.contains("Patreon unlinked.")); + when(f.player.isOnline()).thenReturn(false); + f.messages.clear(); + run(f.player, "unlink"); + assertTrue(f.messages.isEmpty()); + + assertEquals(List.of("unlink"), command.onTabComplete(f.player, null, "patreon", new String[] { "" })); + assertEquals(List.of("unlink"), command.onTabComplete(f.player, null, "patreon", new String[] { "UN" })); + assertTrue(command.onTabComplete(f.player, null, "patreon", new String[] { "x" }).isEmpty()); + assertTrue(command.onTabComplete(f.player, null, "patreon", new String[] { "unlink", "" }).isEmpty()); + assertTrue(command.onTabComplete(f.player, null, "patreon", new String[0]).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java index e045fc9..9cb111f 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/managers/PlayerAndAdminCommandsTest.java @@ -56,10 +56,10 @@ class PlayerAndAdminCommandsTest { @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="";Cache.pluginKey="";f.api.when(ProvinceSystemClient::ping).thenReturn(SimpleResult.success());run(cmd,f.console,"status");assertTrue(f.contains("API reachable"));assertTrue(f.contains("writer=off")); 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(); + run(cmd,f.console,"reload");verify(f.plugin).reloadConfig();assertTrue(f.contains("patreon:")); TFMCWeb plugin=mock(TFMCWeb.class);run(new WebCommand(plugin,f.cache,f.gate),f.console,"reload");verify(plugin).reloadLocalConfig(); } @Test void webLookupAndUnlinkHandleOfflineUnknownFailuresAndGating() { diff --git a/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java new file mode 100644 index 0000000..8c6b508 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java @@ -0,0 +1,259 @@ +package net.tfminecraft.tfmcweb.patreon; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; + +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.messaging.MessagingService; +import net.luckperms.api.model.data.DataMutateResult; +import net.luckperms.api.model.data.NodeMap; +import net.luckperms.api.model.user.User; +import net.luckperms.api.model.user.UserManager; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.NodeBuilderRegistry; +import net.luckperms.api.node.types.InheritanceNode; +import net.luckperms.api.context.ImmutableContextSet; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class LuckPermsPatreonGroupStoreTest { + private final UUID id = UUID.randomUUID(); + private Logger logger; + private UserManager users; + private User user; + private MessagingService messaging; + private NodeMap data; + private final List live = new ArrayList<>(); + private final List removed = new ArrayList<>(); + private MockedStatic provider; + private final String[] builtGroup = new String[1]; + + @BeforeEach void setup() { + logger = mock(Logger.class); + users = mock(UserManager.class); + messaging = mock(MessagingService.class); + user = mock(User.class); + data = mock(NodeMap.class); + when(user.data()).thenReturn(data); + when(users.isLoaded(id)).thenReturn(false); + when(users.loadUser(id)).thenReturn(CompletableFuture.completedFuture(user)); + when(users.saveUser(user)).thenReturn(CompletableFuture.completedFuture(null)); + when(data.toCollection()).thenAnswer(call -> new ArrayList<>(live)); + when(data.remove(any())).thenAnswer(call -> { + Node node = call.getArgument(0); + removed.add(node); + live.remove(node); + return DataMutateResult.SUCCESS; + }); + when(data.add(any())).thenAnswer(call -> { + live.add(call.getArgument(0)); + return DataMutateResult.SUCCESS; + }); + InheritanceNode.Builder builder = mock(InheritanceNode.Builder.class); + when(builder.group(anyString())).thenAnswer(call -> { + builtGroup[0] = call.getArgument(0); + return builder; + }); + when(builder.build()).thenAnswer(call -> node(builtGroup[0], true, false, true)); + LuckPerms api = mock(LuckPerms.class); + NodeBuilderRegistry registry = mock(NodeBuilderRegistry.class); + when(api.getNodeBuilderRegistry()).thenReturn(registry); + when(registry.forInheritance()).thenReturn(builder); + provider = mockStatic(LuckPermsProvider.class); + provider.when(LuckPermsProvider::get).thenReturn(api); + } + + @AfterEach void cleanup() { provider.close(); } + + LuckPermsPatreonGroupStore store() { return new LuckPermsPatreonGroupStore(users, messaging, logger); } + + InheritanceNode node(String group, boolean value, boolean expired, boolean global) { + InheritanceNode node = mock(InheritanceNode.class); + when(node.getGroupName()).thenReturn(group); + when(node.getValue()).thenReturn(value); + when(node.hasExpired()).thenReturn(expired); + if (global) { + when(node.getContexts()).thenReturn(null); + } else { + ImmutableContextSet contexts = mock(ImmutableContextSet.class); + when(contexts.isEmpty()).thenReturn(false); + when(node.getContexts()).thenReturn(contexts); + } + return node; + } + + @Test void openReadsTheProvider() { + LuckPerms api = mock(LuckPerms.class); + when(api.getUserManager()).thenReturn(users); + when(api.getMessagingService()).thenReturn(java.util.Optional.of(messaging)); + provider.when(LuckPermsProvider::get).thenReturn(api); + assertNotNull(LuckPermsPatreonGroupStore.open()); + } + + @Test void emptyAndNullRequestsDoNotTouchLuckPerms() { + assertFalse(store().setGroups(null, "noble", Set.of())); + assertTrue(store().setGroups(id, null, null)); + assertTrue(store().setGroups(id, " ", Set.of())); + assertTrue(store().setGroups(id, null, Set.of(" ", ""))); + verify(users, never()).loadUser(any()); + } + + @Test void addsAGlobalNodeOnceAndSavesAgainWithoutDuplicatingIt() { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertTrue(store().setGroups(id, " Gilded ", Set.of("gilded", "noble"))); + assertEquals("Gilded", builtGroup[0]); + assertEquals(1, live.size()); + assertTrue(store().setGroups(id, "Gilded", Set.of("GILDED"))); + assertEquals(1, live.size()); + verify(data, times(1)).add(any()); + verify(users, times(2)).saveUser(user); + verify(users, times(2)).cleanupUser(user); + verify(user, never()).setPrimaryGroup(anyString()); + } + + @Test void removesOnlyPositiveGlobalPermanentMappedGroupsAndLeavesPrimaryGroupAlone() { + InheritanceNode noble = node("Noble", true, false, true); + InheritanceNode negated = node("noble", false, false, true); + InheritanceNode temporary = node("noble", true, false, true); + when(temporary.hasExpiry()).thenReturn(true); + InheritanceNode contextual = node("gilded", true, false, false); + InheritanceNode legacy = node("legacy", true, false, true); + InheritanceNode unnamed = node(null, true, false, true); + Node permission = mock(Node.class); + live.addAll(Arrays.asList(null, permission, noble, negated, temporary, contextual, legacy, unnamed)); + when(user.getPrimaryGroup()).thenReturn("Noble"); + Set remove = new LinkedHashSet<>(); + remove.add("noble"); + remove.add(" "); + remove.add(null); + assertTrue(store().setGroups(id, null, remove)); + assertEquals(List.of(noble), removed); + assertFalse(live.contains(noble)); + assertTrue(live.contains(negated)); + assertTrue(live.contains(legacy)); + assertTrue(live.contains(contextual)); + assertTrue(live.contains(temporary)); + verify(user, never()).setPrimaryGroup(anyString()); + verify(users).cleanupUser(user); + } + + @Test void grantAndRemovalNeverChangeThePrimaryGroup() { + live.add(node("noble", true, false, true)); + when(user.getPrimaryGroup()).thenReturn("noble"); + assertTrue(store().setGroups(id, "gilded", Set.of("noble"))); + verify(user, never()).setPrimaryGroup(anyString()); + verify(data, times(1)).add(any()); + } + + @Test void pushesSuccessfulSavesThroughLuckPermsMessaging() { + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(users).saveUser(user); + verify(messaging).pushUserUpdate(user); + } + + @Test void missingOrFailingMessagingDoesNotFailSavedChange() { + assertTrue(new LuckPermsPatreonGroupStore(users, null, logger).setGroups(id, "noble", Set.of())); + doThrow(new IllegalStateException("messaging down")).when(messaging).pushUserUpdate(user); + assertTrue(store().setGroups(id, null, Set.of("noble"))); + verify(logger).log(eq(Level.WARNING), contains("messaging update failed"), any(RuntimeException.class)); + } + + @Test void staffPrimaryGroupAndLoadedUsersAreLeftAlone() { + when(users.isLoaded(id)).thenReturn(true); + when(user.getPrimaryGroup()).thenReturn("legacy"); + live.add(node("noble", true, false, false)); + ImmutableContextSet empty = mock(ImmutableContextSet.class); + when(empty.isEmpty()).thenReturn(true); + InheritanceNode global = node("gilded", true, false, true); + when(global.getContexts()).thenReturn(empty); + live.add(global); + assertTrue(store().setGroups(id, "gilded", Set.of("noble"))); + verify(user, never()).setPrimaryGroup(anyString()); + verify(data, never()).add(any()); + verify(users, never()).cleanupUser(any()); + verify(users).saveUser(user); + } + + @Test void blankPrimaryGroupIsNotReadOrRewritten() { + when(user.getPrimaryGroup()).thenReturn(" "); + assertTrue(store().setGroups(id, null, Set.of("noble"))); + verify(user, never()).setPrimaryGroup(anyString()); + verify(user, never()).getPrimaryGroup(); + } + + @Test void expiredOrContextualGroupIsGrantedAgain() { + live.add(node("noble", true, true, true)); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(data).add(any()); + live.clear(); + live.add(node("noble", true, false, false)); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(data, times(2)).add(any()); + } + + @Test void nullNodeCollectionStillSavesTheGrant() { + when(data.toCollection()).thenReturn(null); + assertTrue(store().setGroups(id, "noble", Set.of("gilded"))); + verify(data).add(any()); + verify(users).saveUser(user); + } + + @Test void alreadyPresentAddIsStillSaved() { + when(data.add(any())).thenReturn(DataMutateResult.FAIL_ALREADY_HAS); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(users).saveUser(user); + } + + @Test void refusedNodeDoesNotSave() { + when(data.add(any())).thenReturn(DataMutateResult.FAIL); + assertFalse(store().setGroups(id, "noble", Set.of("gilded"))); + verify(users, never()).saveUser(any()); + verify(logger).log(eq(Level.WARNING), contains("update failed"), any(RuntimeException.class)); + verify(users).cleanupUser(user); + } + + @Test void failedSaveIsRetriedAndCleanupFailuresAreSwallowed() { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new IllegalStateException("db down")); + when(users.saveUser(user)).thenReturn(failed); + assertFalse(store().setGroups(id, "noble", Set.of())); + assertEquals(1, live.size()); + when(users.saveUser(user)).thenReturn(CompletableFuture.completedFuture(null)); + doThrow(new IllegalStateException("cleanup")).when(users).cleanupUser(user); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(data, times(1)).add(any()); + verify(users, times(2)).saveUser(user); + verify(logger).log(eq(Level.WARNING), contains("cleanup failed"), any(RuntimeException.class)); + } + + @Test void loadAndLookupFailuresLeaveTheChangeUnsaved() { + when(users.loadUser(id)).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("down"))); + assertFalse(store().setGroups(id, "noble", Set.of())); + verify(users, never()).saveUser(any()); + verify(users, never()).cleanupUser(any()); + when(users.loadUser(id)).thenReturn(CompletableFuture.completedFuture(null)); + assertFalse(store().setGroups(id, "noble", Set.of())); + when(user.data()).thenReturn(null); + when(users.loadUser(id)).thenReturn(CompletableFuture.completedFuture(user)); + assertFalse(store().setGroups(id, "noble", Set.of())); + verify(users).cleanupUser(user); + when(users.isLoaded(id)).thenThrow(new IllegalStateException("offline")); + assertFalse(store().setGroups(id, "noble", Set.of())); + verify(logger, atLeastOnce()).log(eq(Level.WARNING), contains("update failed"), any(RuntimeException.class)); + } +} diff --git a/src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java b/src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java new file mode 100644 index 0000000..8b943c4 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/PatreonRankWriterTest.java @@ -0,0 +1,360 @@ +package net.tfminecraft.tfmcweb.patreon; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; + +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.messaging.MessagingService; +import net.luckperms.api.model.user.UserManager; +import net.tfminecraft.tfmcweb.Cache; +import net.tfminecraft.tfmcweb.TestState; +import net.tfminecraft.tfmcweb.api.PatreonClient; +import net.tfminecraft.tfmcweb.api.PatreonClient.AckResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.RankChange; +import net.tfminecraft.tfmcweb.api.PatreonClient.RankChangesResult; +import net.tfminecraft.tfmcweb.api.PatreonClient.RosterMember; +import net.tfminecraft.tfmcweb.api.PatreonClient.RosterResult; +import net.tfminecraft.tfmcweb.managers.WebCommand; +import net.tfminecraft.tfmcweb.cache.LinkCache; +import net.tfminecraft.tfmcweb.gate.DiscordGateService; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class PatreonRankWriterTest { + TestState state; + JavaPlugin plugin; + Logger logger; + BukkitScheduler scheduler; + PluginManager plugins; + Plugin luckPerms; + BukkitTask task; + MockedStatic bukkit; + MockedStatic api; + RecordingStore store; + AtomicLong clock; + List ticks; + PatreonRankWriter writer; + + static final class RecordingStore implements PatreonGroupStore { + final List calls = new ArrayList<>(); + final Set failed = new java.util.HashSet<>(); + final Set boom = new java.util.HashSet<>(); + record Call(UUID player, String ensure, Set remove) {} + @Override public boolean setGroups(UUID player, String ensure, Set remove) { + if (boom.contains(player)) { + throw new IllegalStateException("boom"); + } + calls.add(new Call(player, ensure, remove == null ? Set.of() : Set.copyOf(remove))); + return !failed.contains(player); + } + } + + @BeforeEach void setup() throws Exception { + state = new TestState(); + plugin = mock(JavaPlugin.class); + logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + scheduler = mock(BukkitScheduler.class); + plugins = mock(PluginManager.class); + luckPerms = mock(Plugin.class); + when(luckPerms.isEnabled()).thenReturn(true); + when(plugins.getPlugin("LuckPerms")).thenReturn(luckPerms); + task = mock(BukkitTask.class); + ticks = new ArrayList<>(); + when(scheduler.runTaskTimerAsynchronously(eq(plugin), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(call -> { + ticks.add(call.getArgument(1)); + return task; + }); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + api = mockStatic(PatreonClient.class); + api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of())); + api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of())); + api.when(() -> PatreonClient.ackRankChanges(anyList())).thenReturn(AckResult.success()); + store = new RecordingStore(); + clock = new AtomicLong(1_000_000L); + writer = new PatreonRankWriter(plugin, () -> store, clock::get); + Cache.patreonEnabled = true; + Cache.patreonApplyRanks = true; + Cache.patreonPollSeconds = 15; + Cache.patreonReconcileMinutes = 30; + Cache.patreonGroups = Map.of("noble", "noble", "gilded", "gilded", "ascended", "ascended"); + } + + @AfterEach void cleanup() throws Exception { + api.close(); + bukkit.close(); + state.close(); + } + + void start() { + writer.refresh(); + assertTrue(writer.isRunning()); + } + + void tick() { ticks.getLast().run(); } + + @Test void staysIdleUntilBothFlagsAndLuckPermsAreAvailable() { + Cache.patreonEnabled = false; + writer.refresh(); + Cache.patreonEnabled = true; + Cache.patreonApplyRanks = false; + writer.refresh(); + verify(scheduler, never()).runTaskTimerAsynchronously(any(), any(Runnable.class), anyLong(), anyLong()); + assertFalse(writer.isRunning()); + assertTrue(writer.statusDetail().contains("writer=off")); + assertFalse(writer.statusDetail().contains("LuckPerms")); + Cache.patreonApplyRanks = true; + when(plugins.getPlugin("LuckPerms")).thenReturn(null); + writer.refresh(); + writer.refresh(); + verify(logger, times(1)).warning(contains("not installed")); + when(plugins.getPlugin("LuckPerms")).thenReturn(luckPerms); + when(luckPerms.isEnabled()).thenReturn(false); + writer.refresh(); + verify(logger, times(1)).warning(contains("not installed")); + bukkit.when(Bukkit::getPluginManager).thenReturn(null); + new PatreonRankWriter(plugin, () -> store, clock::get).refresh(); + verify(scheduler, never()).runTaskTimerAsynchronously(any(), any(Runnable.class), anyLong(), anyLong()); + } + + @Test void schedulesPollsAndStopsCleanly() { + start(); + verify(scheduler).runTaskTimerAsynchronously(plugin, ticks.getFirst(), 300L, 300L); + assertTrue(writer.statusDetail().endsWith("writer=on")); + assertTrue(PatreonRankWriter.statusText(true).endsWith("writer=on")); + writer.refresh(); + verify(task).cancel(); + writer.stop(); + writer.stop(); + verify(task, times(2)).cancel(); + assertFalse(writer.isRunning()); + tick(); + api.verify(PatreonClient::listRankChanges, never()); + } + + @Test void missingStoreOrSchedulerDisablesTheWriter() { + writer = new PatreonRankWriter(plugin, () -> null, clock::get); + writer.refresh(); + assertFalse(writer.isRunning()); + verify(logger).warning(contains("unavailable")); + when(scheduler.runTaskTimerAsynchronously(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenReturn(null); + writer = new PatreonRankWriter(plugin, () -> store, clock::get); + writer.refresh(); + assertFalse(writer.isRunning()); + verify(logger).warning(contains("Could not schedule")); + assertThrows(IllegalStateException.class, PatreonRankWriter::openLuckPerms); + LuckPerms luckPermsApi = mock(LuckPerms.class); + when(luckPermsApi.getUserManager()).thenReturn(mock(UserManager.class)); + when(luckPermsApi.getMessagingService()).thenReturn(java.util.Optional.of(mock(MessagingService.class))); + try (MockedStatic provider = mockStatic(LuckPermsProvider.class)) { + provider.when(LuckPermsProvider::get).thenReturn(luckPermsApi); + assertNotNull(PatreonRankWriter.openLuckPerms()); + } + PatreonRankWriter hooked = new PatreonRankWriter(plugin); + hooked.refresh(); + assertFalse(hooked.isRunning()); + verify(logger).log(eq(Level.WARNING), contains("unavailable"), any(Throwable.class)); + } + + @Test void appliesMappedChangesAndAcksOnlySavedRows() { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + Map groups = new LinkedHashMap<>(); + groups.put("noble", " donator "); + groups.put("gilded", "gilded"); + groups.put("ascended", " "); + Cache.patreonGroups = groups; + UUID saved = UUID.randomUUID(); + UUID failed = UUID.randomUUID(); + UUID exploded = UUID.randomUUID(); + store.failed.add(failed); + store.boom.add(exploded); + api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of( + new RankChange(7, saved.toString(), " GILDED ", List.of("noble", "gilded", "legacy", " ")), + new RankChange(8, failed.toString(), "gilded", List.of()), + new RankChange(9, "not-a-uuid", "gilded", List.of()), + new RankChange(10, saved.toString(), "ascended", List.of()), + new RankChange(11, exploded.toString(), null, List.of("noble")), + new RankChange(12, saved.toString(), null, List.of("noble")) + ))); + start(); + tick(); + assertEquals("gilded", store.calls.getFirst().ensure()); + assertEquals(Set.of("donator"), store.calls.getFirst().remove()); + assertTrue(store.calls.stream().noneMatch(call -> call.remove().contains("legacy") || call.remove().contains("ascended"))); + assertEquals(Set.of("donator"), store.calls.get(2).remove()); + assertNull(store.calls.get(2).ensure()); + api.verify(() -> PatreonClient.ackRankChanges(List.of(7, 12))); + verify(logger).info(contains("applied 2")); + verify(logger).warning(contains("was not saved")); + verify(logger).warning(contains("no player UUID")); + verify(logger).warning(contains("unmapped tier")); + verify(logger).warning(contains("skipped unmapped removal")); + verify(logger).log(eq(Level.WARNING), contains("rank change 11"), any(RuntimeException.class)); + } + + @Test void ackFailureDoesNotClaimTheChangeWasApplied() { + UUID player = UUID.randomUUID(); + api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of( + new RankChange(4, player.toString(), "noble", List.of()) + ))); + api.when(() -> PatreonClient.ackRankChanges(anyList())).thenReturn(AckResult.fail("down")); + start(); + tick(); + verify(logger).warning(contains("ack failed")); + verify(logger, never()).info(contains("applied")); + } + + @Test void emptyOrFailedPollsDoNotAck() { + start(); + tick(); + api.verify(() -> PatreonClient.ackRankChanges(anyList()), never()); + api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.fail("down")); + tick(); + verify(logger).warning(contains("rank-changes: down")); + api.when(PatreonClient::listRankChanges).thenThrow(new IllegalStateException("boom")); + api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of())); + tick(); + verify(logger).log(eq(Level.WARNING), contains("rank poll failed"), any(Exception.class)); + } + + @Test void blankPlayerUuidAndTierAreRejectedWithoutAckingTheBatch() { + api.when(PatreonClient::listRankChanges).thenReturn(RankChangesResult.success(List.of( + new RankChange(20, null, "noble", List.of()), + new RankChange(21, UUID.randomUUID().toString(), " ", List.of()) + ))); + start(); + tick(); + assertTrue(store.calls.isEmpty()); + api.verify(() -> PatreonClient.ackRankChanges(anyList()), never()); + verify(logger).warning(contains("no player UUID")); + verify(logger).warning(contains("unmapped tier")); + } + + @Test void reconcileCorrectsRosterDriftAndRetriesFailures() { + UUID keep = UUID.randomUUID(); + UUID clear = UUID.randomUUID(); + UUID broken = UUID.randomUUID(); + store.failed.add(broken); + api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of( + new RosterMember(keep.toString(), "gilded"), + new RosterMember(clear.toString(), null), + new RosterMember("bad", "noble"), + new RosterMember(keep.toString(), "knight"), + new RosterMember(broken.toString(), "noble") + ))); + Cache.patreonPollSeconds = 0; + Cache.patreonReconcileMinutes = 0; + start(); + verify(scheduler).runTaskTimerAsynchronously(plugin, ticks.getFirst(), 20L, 20L); + tick(); + RecordingStore.Call granted = store.calls.getFirst(); + assertEquals(keep, granted.player()); + assertEquals("gilded", granted.ensure()); + assertEquals(Set.of("noble", "ascended"), granted.remove()); + assertFalse(granted.remove().contains("legacy")); + RecordingStore.Call cleared = store.calls.get(1); + assertNull(cleared.ensure()); + assertEquals(Set.of("noble", "gilded", "ascended"), cleared.remove()); + assertEquals(broken, store.calls.get(2).player()); + verify(logger).warning(contains("invalid player UUID")); + verify(logger).warning(contains("unmapped tier")); + verify(logger).warning(contains("roster save failed")); + api.verify(PatreonClient::listRoster, times(1)); + tick(); + api.verify(PatreonClient::listRoster, times(2)); + store.failed.clear(); + tick(); + verify(logger).info(contains("reconciled")); + api.verify(PatreonClient::listRoster, times(3)); + tick(); + api.verify(PatreonClient::listRoster, times(3)); + clock.addAndGet(30 * 60_000L); + tick(); + api.verify(PatreonClient::listRoster, times(4)); + } + + @Test void rosterHttpAndUnexpectedFailuresStayDue() { + api.when(PatreonClient::listRoster).thenReturn(RosterResult.fail("down")); + start(); + tick(); + tick(); + verify(logger, atLeastOnce()).warning(contains("roster: down")); + api.verify(PatreonClient::listRoster, times(2)); + UUID player = UUID.randomUUID(); + store.boom.add(player); + api.when(PatreonClient::listRoster).thenReturn(RosterResult.success(List.of(new RosterMember(player.toString(), "noble")))); + tick(); + verify(logger).log(eq(Level.WARNING), contains("roster reconcile failed"), any(Exception.class)); + Cache.patreonReconcileMinutes = 1; + store.boom.clear(); + writer.refresh(); + tick(); + clock.addAndGet(59_999L); + tick(); + api.verify(PatreonClient::listRoster, times(4)); + clock.addAndGet(1L); + tick(); + api.verify(PatreonClient::listRoster, times(5)); + } + + @Test void stoppingDuringAPollSkipsThatReconcile() { + api.when(PatreonClient::listRankChanges).thenAnswer(call -> { + writer.stop(); + return RankChangesResult.success(List.of()); + }); + start(); + tick(); + api.verify(PatreonClient::listRoster, never()); + } + + @Test void webStatusUsesTheRunningWriter() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission(anyString())).thenReturn(true); + List messages = new ArrayList<>(); + doAnswer(call -> { messages.add(call.getArgument(0)); return null; }).when(sender).sendMessage(anyString()); + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(call -> { + call.getArgument(1, Runnable.class).run(); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { + call.getArgument(1, Runnable.class).run(); + return null; + }); + try (var client = mockStatic(net.tfminecraft.tfmcweb.api.ProvinceSystemClient.class)) { + client.when(net.tfminecraft.tfmcweb.api.ProvinceSystemClient::ping) + .thenReturn(net.tfminecraft.tfmcweb.api.ProvinceSystemClient.SimpleResult.success()); + WebCommand idle = new WebCommand(plugin, new LinkCache(), mock(DiscordGateService.class), writer); + assertTrue(idle.onCommand(sender, null, "web", new String[] { "status" })); + start(); + WebCommand live = new WebCommand(plugin, new LinkCache(), mock(DiscordGateService.class), writer); + assertTrue(live.onCommand(sender, null, "web", new String[] { "reload" })); + } + assertTrue(messages.stream().anyMatch(line -> line.contains("writer=off (LuckPerms unavailable)"))); + assertTrue(messages.stream().anyMatch(line -> line.contains("writer=on"))); + } +} From 4688d64ff5d16adc69a987337cc3af9916cfc70e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:01:01 +0000 Subject: [PATCH 2/3] Skip unchanged LuckPerms saves and restore the test locale Reconcile no longer saves or broadcasts an update for a player whose nodes did not change, and a save that failed is retried on the next request. Only a permanent, context-free node counts as the granted rank, so a temporary node no longer hides a missing permanent one. Clickable links now require a host. A Patreon store test left the default locale set to Turkish, which broke MockBukkit's enum lookup in tests that ran after it on CI. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../patreon/LuckPermsPatreonGroupStore.java | 30 ++++++++--- .../tfmcweb/utils/ChatMessages.java | 3 +- .../tfmcweb/RuntimeUtilitiesTest.java | 1 + .../LuckPermsPatreonGroupStoreTest.java | 51 +++++++++++++++---- 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java index 85af799..364f020 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java +++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java @@ -7,6 +7,7 @@ import java.util.Locale; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import java.util.logging.Logger; @@ -32,6 +33,7 @@ public final class LuckPermsPatreonGroupStore implements PatreonGroupStore { private final UserManager users; private final MessagingService messaging; private final Logger logger; + private final Set pendingSaves = ConcurrentHashMap.newKeySet(); public LuckPermsPatreonGroupStore(UserManager users, MessagingService messaging, Logger logger) { this.users = users; @@ -70,11 +72,20 @@ public boolean setGroups(UUID player, String ensureGroup, Set removeGrou if (data == null) { return false; } - mutate(data, ensure, remove); - // Always save. A previous failed save can leave the loaded user already - // edited in memory; skipping the write would ack a change storage never got. - users.saveUser(user).join(); - pushUpdate(user, player); + boolean changed = mutate(data, ensure, remove); + boolean retryPending = pendingSaves.contains(player); + if (changed || loaded || retryPending) { + try { + users.saveUser(user).join(); + } catch (RuntimeException e) { + pendingSaves.add(player); + throw e; + } + pendingSaves.remove(player); + if (changed || retryPending) { + pushUpdate(user, player); + } + } return true; } catch (RuntimeException e) { logger.log(Level.WARNING, "[patreon] LuckPerms update failed for " + player, e); @@ -101,13 +112,15 @@ private void pushUpdate(User user, UUID player) { } } - private static void mutate(NodeMap data, String ensure, Set remove) { + private static boolean mutate(NodeMap data, String ensure, Set remove) { + boolean changed = false; if (ensure != null && !hasGlobal(data, ensure)) { InheritanceNode created = InheritanceNode.builder(ensure).build(); DataMutateResult added = data.add(created); if (added == DataMutateResult.FAIL) { throw new IllegalStateException("LuckPerms refused group " + ensure); } + changed |= added == DataMutateResult.SUCCESS; } for (InheritanceNode node : inheritance(data)) { if (node.getGroupName() == null || !node.getValue()) { @@ -122,14 +135,15 @@ private static void mutate(NodeMap data, String ensure, Set remove) { if (node.getContexts() != null && !node.getContexts().isEmpty()) { continue; } - data.remove(node); + changed |= data.remove(node) == DataMutateResult.SUCCESS; } + return changed; } private static boolean hasGlobal(NodeMap data, String group) { String key = group.toLowerCase(Locale.ROOT); for (InheritanceNode node : inheritance(data)) { - if (node.getGroupName() == null || !node.getValue() || node.hasExpired()) { + if (node.getGroupName() == null || !node.getValue() || node.hasExpiry()) { continue; } if (!key.equals(node.getGroupName().toLowerCase(Locale.ROOT))) { diff --git a/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java b/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java index ea0ed5a..20b68b0 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java +++ b/src/main/java/net/tfminecraft/tfmcweb/utils/ChatMessages.java @@ -54,7 +54,8 @@ private static boolean httpUrl(String url) { return false; } String normalized = scheme.toLowerCase(Locale.ROOT); - return "http".equals(normalized) || "https".equals(normalized); + return uri.getHost() != null + && ("http".equals(normalized) || "https".equals(normalized)); } catch (IllegalArgumentException e) { return false; } diff --git a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java index 541eed6..09b0289 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java @@ -57,6 +57,7 @@ class RuntimeUtilitiesTest { ChatMessages.sendOpenUrl(player,"broken","not a url");verify(player).sendMessage(ChatMessages.PREFIX+"not a url"); ChatMessages.sendOpenUrl(player,"plain","not-a-url");verify(player).sendMessage(ChatMessages.PREFIX+"not-a-url"); ChatMessages.sendOpenUrl(player,"mail","mailto:a@b.test");verify(player).sendMessage(ChatMessages.PREFIX+"mailto:a@b.test"); + ChatMessages.sendOpenUrl(player,"missing host","https:authorize");verify(player).sendMessage(ChatMessages.PREFIX+"https:authorize"); } @Test void cooldownPermissionOrderAndStatuses() { Player player=mock(Player.class);UUID id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id); diff --git a/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java index 8c6b508..ca7f53e 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java @@ -26,12 +26,14 @@ import net.luckperms.api.node.NodeBuilderRegistry; import net.luckperms.api.node.types.InheritanceNode; import net.luckperms.api.context.ImmutableContextSet; +import net.tfminecraft.tfmcweb.TestState; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; class LuckPermsPatreonGroupStoreTest { + private TestState state; private final UUID id = UUID.randomUUID(); private Logger logger; private UserManager users; @@ -43,7 +45,8 @@ class LuckPermsPatreonGroupStoreTest { private MockedStatic provider; private final String[] builtGroup = new String[1]; - @BeforeEach void setup() { + @BeforeEach void setup() throws Exception { + state = new TestState(); logger = mock(Logger.class); users = mock(UserManager.class); messaging = mock(MessagingService.class); @@ -78,7 +81,7 @@ class LuckPermsPatreonGroupStoreTest { provider.when(LuckPermsProvider::get).thenReturn(api); } - @AfterEach void cleanup() { provider.close(); } + @AfterEach void cleanup() throws Exception { provider.close(); state.close(); } LuckPermsPatreonGroupStore store() { return new LuckPermsPatreonGroupStore(users, messaging, logger); } @@ -113,7 +116,7 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa verify(users, never()).loadUser(any()); } - @Test void addsAGlobalNodeOnceAndSavesAgainWithoutDuplicatingIt() { + @Test void addsAGlobalNodeOnceAndSkipsUnchangedSave() { Locale.setDefault(Locale.forLanguageTag("tr-TR")); assertTrue(store().setGroups(id, " Gilded ", Set.of("gilded", "noble"))); assertEquals("Gilded", builtGroup[0]); @@ -121,8 +124,9 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa assertTrue(store().setGroups(id, "Gilded", Set.of("GILDED"))); assertEquals(1, live.size()); verify(data, times(1)).add(any()); - verify(users, times(2)).saveUser(user); + verify(users, times(1)).saveUser(user); verify(users, times(2)).cleanupUser(user); + verify(messaging, times(1)).pushUserUpdate(user); verify(user, never()).setPrimaryGroup(anyString()); } @@ -197,7 +201,9 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa } @Test void expiredOrContextualGroupIsGrantedAgain() { - live.add(node("noble", true, true, true)); + InheritanceNode expired = node("noble", true, true, true); + when(expired.hasExpiry()).thenReturn(true); + live.add(expired); assertTrue(store().setGroups(id, "noble", Set.of())); verify(data).add(any()); live.clear(); @@ -206,6 +212,31 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa verify(data, times(2)).add(any()); } + @Test void temporaryGlobalGroupDoesNotSatisfyPermanentGrant() { + InheritanceNode temporary = node("noble", true, false, true); + when(temporary.hasExpiry()).thenReturn(true); + live.add(temporary); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(data).add(any()); + verify(users).saveUser(user); + } + + @Test void unchangedUnloadedUserIsNotSavedOrPushed() { + live.add(node("noble", true, false, true)); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(users, never()).saveUser(any()); + verify(messaging, never()).pushUserUpdate(any()); + verify(users).cleanupUser(user); + } + + @Test void unchangedLoadedUserIsSavedWithoutPushingUpdate() { + when(users.isLoaded(id)).thenReturn(true); + live.add(node("noble", true, false, true)); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(users).saveUser(user); + verify(messaging, never()).pushUserUpdate(any()); + } + @Test void nullNodeCollectionStillSavesTheGrant() { when(data.toCollection()).thenReturn(null); assertTrue(store().setGroups(id, "noble", Set.of("gilded"))); @@ -213,10 +244,10 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa verify(users).saveUser(user); } - @Test void alreadyPresentAddIsStillSaved() { + @Test void alreadyPresentAddDoesNotSaveUnchangedData() { when(data.add(any())).thenReturn(DataMutateResult.FAIL_ALREADY_HAS); assertTrue(store().setGroups(id, "noble", Set.of())); - verify(users).saveUser(user); + verify(users, never()).saveUser(any()); } @Test void refusedNodeDoesNotSave() { @@ -228,16 +259,18 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa } @Test void failedSaveIsRetriedAndCleanupFailuresAreSwallowed() { + LuckPermsPatreonGroupStore store = store(); CompletableFuture failed = new CompletableFuture<>(); failed.completeExceptionally(new IllegalStateException("db down")); when(users.saveUser(user)).thenReturn(failed); - assertFalse(store().setGroups(id, "noble", Set.of())); + assertFalse(store.setGroups(id, "noble", Set.of())); assertEquals(1, live.size()); when(users.saveUser(user)).thenReturn(CompletableFuture.completedFuture(null)); doThrow(new IllegalStateException("cleanup")).when(users).cleanupUser(user); - assertTrue(store().setGroups(id, "noble", Set.of())); + assertTrue(store.setGroups(id, "noble", Set.of())); verify(data, times(1)).add(any()); verify(users, times(2)).saveUser(user); + verify(messaging).pushUserUpdate(user); verify(logger).log(eq(Level.WARNING), contains("cleanup failed"), any(RuntimeException.class)); } From c2fc68c304360f42d8e3a73dddf949017703de71 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:34:03 +0000 Subject: [PATCH 3/3] Treat a refused LuckPerms removal as a failed change A removal LuckPerms refused was counted as success, so the outbox row was acknowledged while the group was still present. The store now checks that every requested removal is gone and reports failure otherwise, so the change is retried. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../patreon/LuckPermsPatreonGroupStore.java | 5 +++++ .../patreon/LuckPermsPatreonGroupStoreTest.java | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java index 364f020..ddfb13b 100644 --- a/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java +++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java @@ -137,6 +137,11 @@ private static boolean mutate(NodeMap data, String ensure, Set remove) { } changed |= data.remove(node) == DataMutateResult.SUCCESS; } + for (String group : remove) { + if (hasGlobal(data, group)) { + throw new IllegalStateException("LuckPerms refused to remove group " + group); + } + } return changed; } diff --git a/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java index ca7f53e..a8277e5 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java @@ -258,6 +258,21 @@ InheritanceNode node(String group, boolean value, boolean expired, boolean globa verify(users).cleanupUser(user); } + @Test void refusedRemovalDoesNotReportOrPushASavedChange() { + InheritanceNode noble = node("noble", true, false, true); + live.add(noble); + doReturn(DataMutateResult.FAIL).when(data).remove(noble); + + assertFalse(store().setGroups(id, null, Set.of("noble"))); + + assertTrue(live.contains(noble)); + verify(data).remove(noble); + verify(users, never()).saveUser(any()); + verify(messaging, never()).pushUserUpdate(any()); + verify(logger).log(eq(Level.WARNING), contains("update failed"), any(RuntimeException.class)); + verify(users).cleanupUser(user); + } + @Test void failedSaveIsRetriedAndCleanupFailuresAreSwallowed() { LuckPermsPatreonGroupStore store = store(); CompletableFuture failed = new CompletableFuture<>();