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..ddfb13b --- /dev/null +++ b/src/main/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStore.java @@ -0,0 +1,211 @@ +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.concurrent.ConcurrentHashMap; +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; + private final Set pendingSaves = ConcurrentHashMap.newKeySet(); + + 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; + } + 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); + 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 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()) { + continue; + } + if (!remove.contains(node.getGroupName().toLowerCase(Locale.ROOT))) { + continue; + } + if (node.hasExpiry()) { + continue; + } + if (node.getContexts() != null && !node.getContexts().isEmpty()) { + continue; + } + 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; + } + + 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.hasExpiry()) { + 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..20b68b0 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,39 @@ 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 uri.getHost() != null + && ("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..09b0289 100644 --- a/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java +++ b/src/test/java/net/tfminecraft/tfmcweb/RuntimeUtilitiesTest.java @@ -48,6 +48,16 @@ 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"); + 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/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..a8277e5 --- /dev/null +++ b/src/test/java/net/tfminecraft/tfmcweb/patreon/LuckPermsPatreonGroupStoreTest.java @@ -0,0 +1,307 @@ +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 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; + 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() throws Exception { + state = new TestState(); + 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() throws Exception { provider.close(); state.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 addsAGlobalNodeOnceAndSkipsUnchangedSave() { + 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(1)).saveUser(user); + verify(users, times(2)).cleanupUser(user); + verify(messaging, times(1)).pushUserUpdate(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() { + 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(); + live.add(node("noble", true, false, false)); + assertTrue(store().setGroups(id, "noble", Set.of())); + 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"))); + verify(data).add(any()); + verify(users).saveUser(user); + } + + @Test void alreadyPresentAddDoesNotSaveUnchangedData() { + when(data.add(any())).thenReturn(DataMutateResult.FAIL_ALREADY_HAS); + assertTrue(store().setGroups(id, "noble", Set.of())); + verify(users, never()).saveUser(any()); + } + + @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 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<>(); + 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(messaging).pushUserUpdate(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"))); + } +}