Compare commits

..
Author SHA1 Message Date
Ben 9b3f4c939c Correct NeoForge support metadata and documentation 2026-10-16 14:08:03 -06:00
Ben e40bf57093 Merge pull request #1719 from BenCodez/refactor/standardize-test-packages
Standardize test packages
2026-10-16 13:10:08 -06:00
Ben 0c512eb8c1 Standardize test packages 2026-10-16 12:44:16 -06:00
Ben de9852a974 Merge pull request #1718 from BenCodez/maintenance/consolidated-dependency-refresh
Consolidate dependency updates and refresh Spigot API
2026-10-16 11:53:51 -06:00
BenCodez ad0c58d945 Refresh Spigot API and consolidate dependency updates 2026-10-16 11:19:55 -06:00
BenCodez dea8c3a8ba Add support for opening vote streaks menu via votegui
- Implemented command handling to open the vote streaks menu when the
"streaks" command is issued.
2026-10-16 10:57:51 -06:00
Ben f79e4c5d36 Merge pull request #1716 from BenCodez/feature/vote-streaks-menu
Add a read-only vote streak progression menu
2026-10-16 10:49:52 -06:00
Ben ca591ed39c Merge pull request #1708 from BenCodez/feature/configurable-vote-command
Make the player vote command configurable
2026-10-16 10:25:20 -06:00
BenCodez ccfe5f1cf1 Preserve custom vote streaks command precedence 2026-10-15 11:28:47 -06:00
BenCodez 2b59b83123 Merge master and preserve streak-menu cleanup 2026-10-15 10:56:21 -06:00
Ben 1fbc1a1939 Merge pull request #1713 from BenCodez/fix/auto-create-site-setup-reminder
Warn when automatic vote-site creation remains enabled
2026-10-15 09:12:10 -06:00
BenCodez c6a099fdb1 Add a read-only vote streak progression menu 2026-10-15 09:02:42 -06:00
BenCodez 03bbdb4c93 Warn after startup while automatic vote-site creation remains enabled 2026-10-14 10:41:30 -06:00
BenCodez 4a2dae0e47 Use a brief automatic site setup reminder without extra settings 2026-10-14 08:44:22 -06:00
BenCodez 1ce14cc0ec Preserve reminder checkpoint ownership during recovery 2026-10-14 08:20:09 -06:00
BenCodez e2f326de2a Add owner reminder for automatic vote-site setup 2026-10-14 07:35:48 -06:00
BenCodez 0518038d91 Make player vote command registration configurable 2026-10-12 00:00:18 -06:00
248 changed files with 9854 additions and 5360 deletions

No files matched your search

+6
View File
@@ -26,6 +26,8 @@ mvn -B -f VotingPlugin/pom.xml -Dtest=BackendControlConnectorProtocolTest,Contro
CI runs `mvn -B -f VotingPlugin/pom.xml package`; see `.github/workflows/maven.yml`. Do not use the `dev` Maven profile in
automation because it copies a JAR into a developer-specific server directory.
All project tests and test-only helpers belong under `VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/`, with package `com.bencodez.votingplugin.tests` or a meaningful subpackage. Keep paths and package declarations aligned, and update cross-test imports, resources, and build selectors when moving tests. Preserve package-phase artifact checks and test discovery. Do not widen production APIs to make tests accessible; use narrowly scoped test-only reflection for non-public seams. Platform API fixtures that must retain an external runtime class identity (such as `net.minecraft.network.chat.Component`) stay in that platform package and must not contain test cases.
Assign one owner to Maven validation in each worktree. Never run concurrent Maven commands in the same worktree, including focused tests alongside a clean/package build: compilation and cleanup share `target/` and can invalidate each other. Reviewers must inspect source only while that owner builds, or run validation in a separate worktree.
Keep the downloadable VotingPlugin JAR as small as practical. The 10 MiB size target is advisory:
@@ -58,6 +60,10 @@ For compatibility-sensitive changes, add regression coverage that exercises the
- `com.bencodez.votingplugin.core` and its subpackages are platform-independent. Classes there must not import Bukkit, Paper, Fabric, Forge, or NeoForge APIs. Keep loader-specific adapters outside this package and enforce the boundary with `CorePlatformIsolationTest`.
- `proxy/VotingPluginProxy` and the Bungee/Velocity platform packages own proxy lifecycle and vote routing.
- `listeners/` receives Bukkit-side vote/player events; `proxy/cache/` owns proxy pending-vote queues.
- `commands/CommandLoader` captures `VoteCommandEnabled`, `VoteCommandName` and `LoadCommandAliases` at startup.
Configuration reload must not change the registered player root or generated help/navigation. Preserve exact command
ownership, admin roots/aliases and canonical `VotingPlugin.Commands.Vote.*` permissions when disabling or renaming;
standalone player aliases retain their `vote...`/`v...` identities. See `docs/player-command-root.md`.
- `votesites/` resolves configured service names. Be alert to the distinction between read-only resolution and paths that
may auto-create a site.
- `user/` owns player totals, points, streaks, last-vote values, and backend offline rewards.
+6 -6
View File
@@ -382,7 +382,7 @@
<dependency>
<groupId>net.neoforged</groupId>
<artifactId>mergetool</artifactId>
<version>2.0.0</version>
<version>2.0.7</version>
<classifier>api</classifier>
<scope>provided</scope>
</dependency>
@@ -406,7 +406,7 @@
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>26.2-R0.1-SNAPSHOT</version>
<version>26.3-R0.1-SNAPSHOT</version>
<scope>provided</scope>
<exclusions>
<exclusion>
@@ -475,7 +475,7 @@
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>7.5.3</version>
<version>8.0.1</version>
</dependency>
<dependency>
<groupId>me.clip</groupId>
@@ -525,7 +525,7 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.12.2</version>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -537,13 +537,13 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.12.2</version>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.23.0</version>
<version>5.24.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -71,11 +71,9 @@ import com.bencodez.votingplugin.broadcast.BroadcastSettings;
import com.bencodez.votingplugin.proxy.BungeeMethod;
import com.bencodez.votingplugin.commands.CommandLoader;
import com.bencodez.votingplugin.commands.executers.CommandAdminVote;
import com.bencodez.votingplugin.commands.executers.CommandVote;
import com.bencodez.votingplugin.commands.gui.AdminGUI;
import com.bencodez.votingplugin.commands.tabcompleter.AdminVoteTabCompleter;
import com.bencodez.votingplugin.commands.tabcompleter.AliasCommandFilterListener;
import com.bencodez.votingplugin.commands.tabcompleter.VoteTabCompleter;
import com.bencodez.votingplugin.config.BungeeSettings;
import com.bencodez.votingplugin.config.Config;
import com.bencodez.votingplugin.config.ConfigVoteSites;
@@ -87,6 +85,9 @@ import com.bencodez.votingplugin.cooldown.CoolDownCheck;
import com.bencodez.votingplugin.control.BackendControlAutoEnrollment;
import com.bencodez.votingplugin.control.BackendControlConnector;
import com.bencodez.votingplugin.data.ServerData;
import com.bencodez.votingplugin.maintenance.AutoCreateVoteSitesReminderService;
import com.bencodez.votingplugin.maintenance.AutoCreateVoteSitesReminderService.Settings;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
import com.bencodez.votingplugin.discord.DiscordHandler;
import com.bencodez.votingplugin.listeners.BlockBreak;
import com.bencodez.votingplugin.integration.votifier.VotifierIntegration;
@@ -156,49 +157,6 @@ import lombok.Setter;
public class VotingPluginMain extends AdvancedCorePlugin {
private final Object votingNPCLifecycle = new Object();
private final java.util.concurrent.atomic.AtomicLong votingNPCConfiguration = new java.util.concurrent.atomic.AtomicLong();
private volatile com.bencodez.votingplugin.npc.VotingNPCManager votingNPCManager;
public com.bencodez.votingplugin.npc.VotingNPCManager getVotingNPCManager() { return votingNPCManager; }
/** Optional NPC storage is lazy and asynchronous; configuration only schedules owner work. */
private void refreshVotingNPCs() {
boolean requested = getConfigFile().isVotingNPCsEnabled();
long generation = votingNPCConfiguration.incrementAndGet();
if (!requested && votingNPCManager == null) return;
getBukkitScheduler().runTask(this, () -> {
com.bencodez.votingplugin.npc.VotingNPCManager manager;
synchronized (votingNPCLifecycle) {
if (!isEnabled() || generation != votingNPCConfiguration.get()) return;
if (votingNPCManager == null && requested) {
try {
var created = new com.bencodez.votingplugin.npc.VotingNPCManager(this);
Bukkit.getPluginManager().registerEvents(created, this);
votingNPCManager = created;
} catch (RuntimeException | LinkageError unsupported) {
getLogger().warning("Voting NPCs are unavailable on this server; normal voting is unaffected.");
return;
}
}
manager = votingNPCManager;
}
if (manager != null) manager.configure(requested);
});
}
private void closeVotingNPCs() {
com.bencodez.votingplugin.npc.VotingNPCManager manager;
synchronized (votingNPCLifecycle) {
votingNPCConfiguration.incrementAndGet();
manager = votingNPCManager;
votingNPCManager = null;
}
if (manager != null) {
try { manager.close(); } catch (RuntimeException | LinkageError failure) { debug(failure); }
}
}
@Getter
public static VotingPluginMain plugin;
@@ -217,6 +175,9 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Getter
private BroadcastHandler broadcastHandler;
@Getter
private com.bencodez.votingplugin.streaks.VoteStreaksMenu voteStreaksMenu;
@Getter
private BackendProxyHandler backendProxyHandler;
private ProcessedVoteCache backendProcessedVoteCache;
@@ -256,6 +217,21 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Getter
private CommandLoader commandLoader;
/** Runtime command identity is captured at startup; configuration reload does not rename it. */
public String getVoteCommandPrefix() {
return "/" + (commandLoader == null ? "vote" : commandLoader.getVoteCommandName());
}
public boolean isVoteCommandEnabled() {
return commandLoader == null || commandLoader.isVoteCommandEnabled();
}
/** Retarget the built-in invalid-command help hint, including existing Config.yml text. */
public String formatVoteCommandMessage(String message) {
return message.replace("%votecommand%", getVoteCommandPrefix())
.replaceAll("(?i)/vote(?=\\s|$|[!.,])", getVoteCommandPrefix());
}
@Getter
private NameMCLikeCheckerTask nameMCLikeCheckerTask;
@@ -339,6 +315,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Getter
private ScheduledExecutorService voteTimer;
private volatile AutoCreateVoteSitesReminderService autoCreateVoteSitesSetupReminder;
private ScheduledExecutorService autoCreateVoteSitesReminderWorker;
@Getter
private UserManager votingPluginUserManager;
@@ -765,6 +743,16 @@ public class VotingPluginMain extends AdvancedCorePlugin {
voteTester = new VoteTester(plugin);
loadVoteTimer();
BoundedScheduledExecutor setupReminderWorker = new BoundedScheduledExecutor(1, 4);
setupReminderWorker.setThreadFactory(runnable -> {
Thread worker = new Thread(runnable, "VotingPlugin-SiteSetupReminder");
worker.setDaemon(true);
return worker;
});
autoCreateVoteSitesReminderWorker = setupReminderWorker;
autoCreateVoteSitesSetupReminder = new AutoCreateVoteSitesReminderService(getDataFolder().toPath(),
setupReminderWorker, getLogger(), autoCreateVoteSitesReminderSettings());
autoCreateVoteSitesSetupReminder.start();
getVotingPluginUserManager().startSharedPointTransferRecovery();
if (bungeeSettings.isUseBungeecoord()) {
@@ -791,7 +779,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
checkVotifier();
registerEvents();
refreshPlaceholderPlayerPresence();
refreshVotingNPCs();
loadVoteBroadcast();
@@ -1988,6 +1975,7 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Override
public void onDisable() {
stopAutoCreateVoteSitesSetupReminder();
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close();
try {
shutdownVoteReminders();
@@ -1996,6 +1984,24 @@ public class VotingPluginMain extends AdvancedCorePlugin {
}
}
private Settings autoCreateVoteSitesReminderSettings() {
return new Settings(true, configFile.isAutoCreateVoteSites(),
AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS,
AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
}
private void stopAutoCreateVoteSitesSetupReminder() {
AutoCreateVoteSitesReminderService current = autoCreateVoteSitesSetupReminder;
autoCreateVoteSitesSetupReminder = null;
ScheduledExecutorService worker = autoCreateVoteSitesReminderWorker;
autoCreateVoteSitesReminderWorker = null;
try {
if (current != null) current.closeAsync();
} finally {
if (worker != null) worker.shutdown();
}
}
private void shutdownVoteReminders() {
if (voteRemindersManager != null) {
voteRemindersManager.shutdown();
@@ -2005,7 +2011,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
@Override
public void onUnLoad() {
closeVotingNPCs();
if (voteStreaksMenu != null) voteStreaksMenu.close();
stopAutoCreateVoteSitesSetupReminder();
placeholderPlayerPresence.clear();
stopBackendHostedControlLifecycle();
stopBackendControlConnectorLifecycle();
@@ -2058,12 +2065,6 @@ public class VotingPluginMain extends AdvancedCorePlugin {
commandLoader.loadCommands();
commandLoader.loadAliases();
// /vote, /v
getCommand("vote").setExecutor(new CommandVote(this));
getCommand("vote").setTabCompleter(new VoteTabCompleter());
// getCommand("v").setExecutor(new CommandVote(this));
// getCommand("v").setTabCompleter(new VoteTabCompleter());
// /adminvote, /av
getCommand("adminvote").setExecutor(new CommandAdminVote(this));
getCommand("adminvote").setTabCompleter(new AdminVoteTabCompleter());
@@ -2090,6 +2091,8 @@ public class VotingPluginMain extends AdvancedCorePlugin {
* Register events.
*/
private void registerEvents() {
voteStreaksMenu = new com.bencodez.votingplugin.streaks.VoteStreaksMenu(this);
getServer().getPluginManager().registerEvents(voteStreaksMenu, this);
PluginManager pm = getServer().getPluginManager();
pm.registerEvents(new PlayerJoinEvent(this), this);
@@ -2170,8 +2173,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
boolean updateActiveBackendRuntime) {
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.pause();
try {
if (voteStreaksMenu != null) voteStreaksMenu.suspend();
configFile.reloadData();
configFile.loadValues();
AutoCreateVoteSitesReminderService setupReminder = autoCreateVoteSitesSetupReminder;
if (setupReminder != null) setupReminder.reload(autoCreateVoteSitesReminderSettings());
configVoteSites.reloadData();
@@ -2217,11 +2223,11 @@ public class VotingPluginMain extends AdvancedCorePlugin {
loadDirectlyDefined();
refreshVotingNPCs();
if (reconcileHostedControl) restartBackendControlConnector();
setUpdate(true);
} finally {
if (voteStreaksMenu != null) voteStreaksMenu.resume();
if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.resume();
}
}
@@ -56,6 +56,7 @@ import com.bencodez.simpleapi.valuerequest.ValueRequest;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.core.maintenance.MaintenanceConfirmation;
import com.bencodez.votingplugin.commands.executers.CommandAliases;
import com.bencodez.votingplugin.commands.executers.CommandVote;
import com.bencodez.votingplugin.commands.gui.AdminGUI;
import com.bencodez.votingplugin.commands.gui.admin.AdminVoteHelp;
import com.bencodez.votingplugin.commands.gui.admin.AdminVotePerms;
@@ -82,6 +83,7 @@ import com.bencodez.votingplugin.commands.gui.player.VoteTotal;
import com.bencodez.votingplugin.commands.gui.player.VoteURL;
import com.bencodez.votingplugin.commands.gui.player.VoteURLVoteSite;
import com.bencodez.votingplugin.commands.tabcompleter.AliasesTabCompleter;
import com.bencodez.votingplugin.commands.tabcompleter.VoteTabCompleter;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent;
import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler;
@@ -115,6 +117,13 @@ public class CommandLoader {
private String playerPerm = "VotingPlugin.Player";
private VotingPluginMain plugin;
private final boolean startupVoteCommandEnabled;
private final String startupVoteCommandName;
private final boolean startupLoadCommandAliases;
private boolean playerCommandConfigured;
private PluginCommand playerRootCommand;
private boolean voteCommandEnabled;
private String voteCommandName = "vote";
/**
* Constructs a new CommandLoader.
@@ -123,6 +132,131 @@ public class CommandLoader {
*/
public CommandLoader(VotingPluginMain plugin) {
this.plugin = plugin;
var config = plugin.getConfigFile();
startupVoteCommandEnabled = config == null || config.isVoteCommandEnabled();
startupVoteCommandName = config == null ? "vote" : config.getVoteCommandName();
startupLoadCommandAliases = config == null || config.isLoadCommandAliases();
voteCommandEnabled = startupVoteCommandEnabled;
}
/** The label actually registered at startup, including its namespace on conflict. */
public String getVoteCommandName() {
return voteCommandName;
}
public boolean isVoteCommandEnabled() {
return voteCommandEnabled;
}
/** Runs a generated player navigation action only while its command is available. */
public void performVoteCommand(Player player, String arguments) {
if (voteCommandEnabled) {
player.performCommand(voteCommandName + (arguments.isEmpty() ? "" : " " + arguments));
}
}
/** Configure only once: reloading configuration must not change command ownership. */
private void configurePlayerCommand() {
if (playerCommandConfigured) return;
playerCommandConfigured = true;
try {
CommandMap commandMap = getCommandMap();
PluginCommand root = ownedCommand("vote");
playerRootCommand = root;
if (!startupVoteCommandEnabled) {
for (String name : plugin.getDescription().getCommands().keySet()) {
if (name.equalsIgnoreCase("vote") || name.toLowerCase(Locale.ROOT).startsWith("vote")) {
PluginCommand command = ownedCommand(name);
if (command != null) unregisterCommand(commandMap, command);
}
}
voteCommandEnabled = false;
return;
}
if (root == null) {
voteCommandEnabled = false;
plugin.getLogger().warning("Unable to locate VotingPlugin's player command; player commands were not enabled.");
return;
}
String name = startupVoteCommandName == null ? "" : startupVoteCommandName.trim().toLowerCase(Locale.ROOT);
if (!name.matches("[a-z0-9][a-z0-9_-]{0,63}") || isReservedCommandName(name)) {
plugin.getLogger().warning("Invalid or reserved VoteCommandName; using vote. Command settings require a restart.");
name = "vote";
}
String namespace = plugin.getDescription().getName().toLowerCase(Locale.ROOT);
String fallback = namespace + ":" + name;
org.bukkit.command.Command existingFallback = commandMap.getCommand(fallback);
org.bukkit.command.Command existingLabel = commandMap.getCommand(name);
boolean ownedLabelCollision = existingLabel instanceof PluginCommand owned
&& owned.getPlugin() == plugin && owned != root;
if (!name.equals("vote") && ((existingFallback != null && existingFallback != root) || ownedLabelCollision)) {
plugin.getLogger().warning("VoteCommandName namespace is already owned; keeping vote. Command settings require a restart.");
name = "vote";
fallback = namespace + ":vote";
}
if (!name.equals("vote")) {
// Bukkit's register writes the fallback even when the bare label collides.
// Check it before unregistering or publishing the renamed command.
unregisterCommand(commandMap, root);
root.setName(name);
root.setLabel(name);
root.setAliases(List.of());
if (existingLabel != null && existingLabel != root) {
// SimpleCommandMap may overwrite another command's bare alias. Register
// only our free fallback in that case, preserving the alias owner too.
root.setLabel(fallback);
if (!root.register(commandMap)) throw new IllegalStateException("Player command registration was rejected");
Map<String, org.bukkit.command.Command> registrations = getKnownCommands(commandMap);
// Paper forwards put/get to Brigadier; HashMap.putIfAbsent can bypass it.
if (registrations.get(fallback) != null) throw new IllegalStateException("Player command namespace became occupied");
registrations.put(fallback, root);
} else {
commandMap.register(namespace, root);
}
}
voteCommandName = commandMap.getCommand(name) == root ? name : fallback;
voteCommandEnabled = commandMap.getCommand(voteCommandName) == root;
if (voteCommandEnabled) {
root.setExecutor(new CommandVote(plugin));
root.setTabCompleter(new VoteTabCompleter());
if (!voteCommandName.equals(name)) {
plugin.getLogger().warning("Player command label is already owned; use /" + voteCommandName + " instead.");
}
} else {
plugin.getLogger().warning("Unable to register VotingPlugin's player command without replacing another owner.");
}
} catch (ReflectiveOperationException | RuntimeException failure) {
throw new IllegalStateException("Unable to configure VotingPlugin's player command safely", failure);
}
}
private boolean isReservedCommandName(String name) {
for (var entry : plugin.getDescription().getCommands().entrySet()) {
if (entry.getKey().equalsIgnoreCase("vote")) continue;
if (entry.getKey().equalsIgnoreCase(name)) return true;
Object aliases = entry.getValue().get("aliases");
if (aliases instanceof String alias && alias.equalsIgnoreCase(name)) return true;
if (aliases instanceof Iterable<?> values) {
for (Object alias : values) if (name.equalsIgnoreCase(String.valueOf(alias))) return true;
}
}
return false;
}
private PluginCommand ownedCommand(String name) {
PluginCommand command = plugin.getCommand(name);
if (command != null && command.getPlugin() == plugin) return command;
try {
// A plugin may have displaced the primary and fallback labels while a short
// alias still points at our command. Disable must remove that object too.
for (org.bukkit.command.Command candidate : getKnownCommands(getCommandMap()).values()) {
if (candidate instanceof PluginCommand owned && owned.getPlugin() == plugin
&& owned.getName().equalsIgnoreCase(name)) return owned;
}
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Unable to inspect owned command registrations", failure);
}
return null;
}
void runForCommandSender(CommandSender sender, Runnable task) {
@@ -403,26 +537,28 @@ public class CommandLoader {
* @return true if player has permission
*/
public boolean hasPermission(Player player, String cmd) {
cmd = cmd.toLowerCase(Locale.ROOT);
if (cmd.startsWith("votingplugin:")) {
cmd = cmd.substring("votingplugin:".length());
}
if (commands.containsKey(cmd)) {
return commands.get(cmd).hasPerm(player);
for (var entry : commands.entrySet()) {
if (entry.getKey().equalsIgnoreCase(cmd)) return entry.getValue().hasPerm(player);
}
boolean adminCommand = false;
String rootLabel = voteCommandName.substring(voteCommandName.indexOf(':') + 1);
if (cmd.startsWith("vote")) {
cmd = cmd.substring("vote".length());
} else if (cmd.startsWith("v")) {
cmd = cmd.substring(1);
} else if (cmd.startsWith("av")) {
if (cmd.equals("adminvote") || cmd.startsWith("adminvote ")) {
adminCommand = true;
cmd = cmd.substring("av".length());
} else if (cmd.startsWith("adminvote")) {
cmd = cmd.substring("adminvote".length()).trim();
} else if (cmd.equals("av") || cmd.startsWith("av ")) {
adminCommand = true;
cmd = cmd.substring("adminvote".length());
cmd = cmd.substring("av".length()).trim();
} else if (voteCommandEnabled && (cmd.equals(rootLabel) || cmd.startsWith(rootLabel + " "))) {
cmd = cmd.substring(rootLabel.length()).trim();
} else {
return false;
}
// plugin.debug(cmd);
if (!adminCommand) {
@@ -459,13 +595,11 @@ public class CommandLoader {
* @return true if it's a VotingPlugin command
*/
public boolean isVotingPluginCommand(Player player, String cmd) {
if (plugin.getCommand(cmd) != null || cmd.startsWith("votingplugin")) {
return true;
}
for (String str : commands.keySet()) {
if (str.equalsIgnoreCase(cmd)) {
return true;
}
try {
org.bukkit.command.Command command = getCommandMap().getCommand(cmd);
return command instanceof PluginCommand owned && owned.getPlugin() == plugin;
} catch (ReflectiveOperationException failure) {
plugin.debug(failure);
}
return false;
}
@@ -475,7 +609,6 @@ public class CommandLoader {
*/
private void loadAdminVoteCommand() {
plugin.setAdminVoteCommand(new ArrayList<>());
com.bencodez.votingplugin.npc.VotingNPCCommands.register(plugin);
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, new String[] { "CurrentPluginTime" },
"VotingPlugin.Commands.AdminVote.CurrentPluginTime|" + adminPerm, "Current plugin time") {
@@ -2970,9 +3103,10 @@ public class CommandLoader {
public void loadAliases() {
commands = new HashMap<>();
aliasCommandNames.clear();
configurePlayerCommand();
// Bukkit registers plugin.yml commands before this loader runs.
if (!plugin.getConfigFile().isLoadCommandAliases()) {
if (!startupLoadCommandAliases) {
unregisterOptionalPluginYMLCommands();
} else {
unregisterDisabledPluginYMLCommands();
@@ -2980,7 +3114,7 @@ public class CommandLoader {
// If false: still wire permissions, but don't wire alias executors/tab
// completers.
final boolean enableAliases = plugin.getConfigFile().isLoadCommandAliases();
final boolean enableAliases = startupLoadCommandAliases;
// ---------------------------
// /vote aliases
@@ -3012,16 +3146,18 @@ public class CommandLoader {
}
registerAdditionalPermissions(Bukkit.getPluginManager(), cmdHandle);
if (argLength > 0) {
if (argLength > 0 && voteCommandEnabled) {
String[] args = cmdHandle.getArgs()[0].split("&");
for (String arg : args) {
String commandName = "vote" + arg;
// A renamed root may equal a synthetic alias absent from plugin.yml.
if (ownedCommand(commandName) == playerRootCommand && playerRootCommand != null) continue;
commands.put(commandName, cmdHandle);
aliasCommandNames.add(commandName.toLowerCase(Locale.ROOT));
try {
PluginCommand command = plugin.getCommand(commandName);
PluginCommand command = ownedCommand(commandName);
if (command == null) {
plugin.devDebug("Command " + commandName + " not found in plugin.yml");
continue;
@@ -3095,11 +3231,13 @@ public class CommandLoader {
for (String arg : args) {
String commandName = "adminvote" + arg;
// A renamed root may equal a synthetic alias absent from plugin.yml.
if (ownedCommand(commandName) == playerRootCommand && playerRootCommand != null) continue;
commands.put(commandName, cmdHandle);
aliasCommandNames.add(commandName.toLowerCase(Locale.ROOT));
try {
PluginCommand command = plugin.getCommand(commandName);
PluginCommand command = ownedCommand(commandName);
if (command == null) {
plugin.devDebug("Command " + commandName + " not found in plugin.yml");
continue;
@@ -3165,8 +3303,8 @@ public class CommandLoader {
|| commandName.equalsIgnoreCase("av")) {
continue;
}
PluginCommand command = plugin.getCommand(commandName);
if (command != null) {
PluginCommand command = ownedCommand(commandName);
if (command != null && command != playerRootCommand) {
unregisterCommand(commandMap, command);
}
}
@@ -3180,8 +3318,8 @@ public class CommandLoader {
try {
CommandMap commandMap = getCommandMap();
for (String commandName : disabledAliasCommandNames) {
PluginCommand command = plugin.getCommand(commandName);
if (command != null) {
PluginCommand command = ownedCommand(commandName);
if (command != null && command != playerRootCommand) {
unregisterCommand(commandMap, command);
}
}
@@ -3193,22 +3331,7 @@ public class CommandLoader {
/** Removes the command and all labels that still point to it from Bukkit's map. */
private void unregisterCommand(CommandMap commandMap, PluginCommand command) throws ReflectiveOperationException {
command.unregister(commandMap);
java.lang.reflect.Field field = null;
Class<?> commandMapClass = commandMap.getClass();
while (commandMapClass != null && field == null) {
try {
field = commandMapClass.getDeclaredField("knownCommands");
} catch (NoSuchFieldException ignored) {
commandMapClass = commandMapClass.getSuperclass();
}
}
if (field == null) {
throw new NoSuchFieldException("knownCommands");
}
field.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, org.bukkit.command.Command> knownCommands =
(Map<String, org.bukkit.command.Command>) field.get(commandMap);
Map<String, org.bukkit.command.Command> knownCommands = getKnownCommands(commandMap);
Set<String> removedLabels = new HashSet<>();
for (Map.Entry<String, org.bukkit.command.Command> entry : knownCommands.entrySet()) {
if (entry.getValue() == command) {
@@ -3237,6 +3360,27 @@ public class CommandLoader {
}
}
/** Reads the same Bukkit-owned map used by exact-object unregister. */
private Map<String, org.bukkit.command.Command> getKnownCommands(CommandMap commandMap) throws ReflectiveOperationException {
java.lang.reflect.Field field = null;
Class<?> commandMapClass = commandMap.getClass();
while (commandMapClass != null && field == null) {
try {
field = commandMapClass.getDeclaredField("knownCommands");
} catch (NoSuchFieldException ignored) {
commandMapClass = commandMapClass.getSuperclass();
}
}
if (field == null) {
throw new NoSuchFieldException("knownCommands");
}
field.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, org.bukkit.command.Command> knownCommands =
(Map<String, org.bukkit.command.Command>) field.get(commandMap);
return knownCommands;
}
/** Gets Bukkit's command map without depending on a CraftBukkit package name. */
private CommandMap getCommandMap() throws ReflectiveOperationException {
java.lang.reflect.Method method = plugin.getServer().getClass().getMethod("getCommandMap");
@@ -3300,14 +3444,6 @@ public class CommandLoader {
* Load tab complete.
*/
public void loadTabComplete() {
TabCompleteHandler.getInstance().addTabCompleteOption(new TabCompleteHandle("(npcid)", new ArrayList<>()) {
@Override public void reload() { updateReplacements(); }
@Override public void updateReplacements() {
var manager = plugin.getVotingNPCManager();
setReplace(manager == null ? new ArrayList<>() : new ArrayList<>(manager.definitions().stream()
.map(com.bencodez.votingplugin.npc.VotingNPCDefinition::id).toList()));
}
});
ArrayList<String> sites = new ArrayList<>();
for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) {
sites.add(site.getKey());
@@ -4078,6 +4214,9 @@ public class CommandLoader {
}
}
// Preserve administrator-defined matches ahead of this new fallback.
com.bencodez.votingplugin.streaks.VoteStreaksCommand.register(plugin);
ArrayList<CommandHandler> avCommands = com.bencodez.advancedcore.command.CommandLoader.getInstance()
.getBasicCommands("VotingPlugin");
for (CommandHandler cmd : avCommands) {
@@ -4144,7 +4283,7 @@ public class CommandLoader {
} else if (MessageAPI.startsWithIgnoreCase(slot, "today")) {
new VoteToday(plugin, player, user, 1).open();
} else if (MessageAPI.startsWithIgnoreCase(slot, "help")) {
player.performCommand("vote help");
performVoteCommand(player, "help");
} else if (MessageAPI.startsWithIgnoreCase(slot, "shop")) {
new VoteShop(plugin, player, user).open();
} else if (MessageAPI.startsWithIgnoreCase(slot, "lastmonth")) {
@@ -4153,6 +4292,8 @@ public class CommandLoader {
new VoteBest(plugin, player, user).open();
} else if (MessageAPI.startsWithIgnoreCase(slot, "streak")) {
new VoteStreak(plugin, player, user).open();
} else if (MessageAPI.startsWithIgnoreCase(slot, "streaks")) {
plugin.getVoteStreaksMenu().open(player);
}
}
@@ -74,7 +74,7 @@ public class CommandAliases implements CommandExecutor {
if (adminCommand) {
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandAdminVote()));
} else {
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandVote()));
sender.sendMessage(MessageAPI.colorize(plugin.formatVoteCommandMessage(plugin.getConfigFile().getFormatInvalidCommandVote())));
}
return true;
}
@@ -35,7 +35,7 @@ public class CommandVote implements CommandExecutor {
}
// invalid command
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandVote()));
sender.sendMessage(MessageAPI.colorize(plugin.formatVoteCommandMessage(plugin.getConfigFile().getFormatInvalidCommandVote())));
return true;
}
@@ -77,16 +77,16 @@ public class AdminVotePerms extends GUIHandler {
for (CommandHandler handle : plugin.getVoteCommand()) {
if (sender instanceof Player) {
if (handle.hasPerm(sender)) {
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &atrue");
} else {
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
}
} else {
msg.add(handle.getHelpLineCommand("/vote") + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
msg.add(handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
}
addAdditionalPermissions(msg, sender, handle, "/vote");
addAdditionalPermissions(msg, sender, handle, plugin.getVoteCommandPrefix());
}
@@ -164,13 +164,13 @@ public class AdminVotePerms extends GUIHandler {
for (CommandHandler handle : plugin.getVoteCommand()) {
if (handle.hasPerm(p)) {
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &atrue");
} else {
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
}
addAdditionalPermissions(msg, p, handle, "/vote");
addAdditionalPermissions(msg, p, handle, plugin.getVoteCommandPrefix());
}
@@ -237,7 +237,7 @@ public class AdminVotePerms extends GUIHandler {
msg.add(" ");
for (CommandHandler handle : plugin.getVoteCommand()) {
msg.add(handle.getHelpLineCommand("/vote"));
msg.add(handle.getHelpLineCommand(plugin.getVoteCommandPrefix()));
msg.add(" " + handle.getPerm());
msg.add(" " + handle.getHelpMessage());
for (String permission : additionalPermissions(handle)) {
@@ -55,6 +55,7 @@ public class VoteHelp extends GUIHandler {
*/
public ArrayList<TextComponent> helpText(CommandSender sender) {
ArrayList<TextComponent> msg = new ArrayList<>();
if (!plugin.isVoteCommandEnabled()) return msg;
HashMap<String, TextComponent> unsorted = new HashMap<>();
boolean requirePerms = plugin.getConfigFile().isFormatCommandsVoteHelpRequirePermission();
@@ -70,7 +71,7 @@ public class VoteHelp extends GUIHandler {
}
for (CommandHandler cmdHandle : plugin.getVoteCommand()) {
if (!requirePerms || cmdHandle.hasPerm(sender)) {
unsorted.put(cmdHandle.getHelpLineCommand("/vote"), cmdHandle.getHelpLine("/vote",
unsorted.put(cmdHandle.getHelpLineCommand(plugin.getVoteCommandPrefix()), cmdHandle.getHelpLine(plugin.getVoteCommandPrefix(),
plugin.getConfigFile().getFormatCommandsVoteHelpLine(), hoverColor));
}
}
@@ -92,14 +93,15 @@ public class VoteHelp extends GUIHandler {
*/
public ArrayList<TextComponent> helpTextLegacy(CommandSender sender) {
ArrayList<TextComponent> msg = new ArrayList<>();
if (!plugin.isVoteCommandEnabled()) return msg;
HashMap<String, TextComponent> unsorted = new HashMap<>();
boolean requirePerms = plugin.getConfigFile().isFormatCommandsVoteHelpRequirePermission();
for (CommandHandler cmdHandle : plugin.getVoteCommand()) {
if (!requirePerms || cmdHandle.hasPerm(sender)) {
unsorted.put(cmdHandle.getHelpLineCommand("/vote"),
cmdHandle.getHelpLine("/vote", plugin.getConfigFile().getFormatCommandsVoteHelpLine()));
unsorted.put(cmdHandle.getHelpLineCommand(plugin.getVoteCommandPrefix()),
cmdHandle.getHelpLine(plugin.getVoteCommandPrefix(), plugin.getConfigFile().getFormatCommandsVoteHelpLine()));
}
}
ArrayList<String> unsortedList = new ArrayList<>();
@@ -177,7 +177,7 @@ public class VoteURL extends GUIHandler {
}
if (plugin.getGui().isChestVoteURLBackButton()) {
dialog.button("&eBack", "&7Return to previous menu", payload -> player.performCommand("vote gui"));
dialog.button("&eBack", "&7Return to previous menu", payload -> plugin.getCommandLoader().performVoteCommand(player, "gui"));
}
dialog.open();
@@ -24,9 +24,6 @@ import com.bencodez.votingplugin.topvoter.TopVoter;
import lombok.Getter;
public class Config extends YMLFile {
/** Opt-in persistent vanilla voting NPCs; missing configuration preserves legacy behavior. */
public boolean isVotingNPCsEnabled() { return getData().getBoolean("VotingNPCs.Enabled", false); }
/** Explicit permission for executable deployment over private-network plaintext HTTP. */
public boolean getControlAllowInsecureHttpPluginDeployment() {
@@ -525,6 +522,15 @@ public class Config extends YMLFile {
@Getter
private boolean limitMonthlyVotes = false;
/** Restart-only player command registration. Missing keys preserve /vote. */
@ConfigDataBoolean(path = "VoteCommandEnabled", defaultValue = true)
@Getter
private boolean voteCommandEnabled = true;
@ConfigDataString(path = "VoteCommandName", defaultValue = "vote")
@Getter
private String voteCommandName = "vote";
@ConfigDataBoolean(path = "LoadCommandAliases")
@Getter
private boolean loadCommandAliases = true;
@@ -26,6 +26,10 @@ import lombok.Getter;
*/
public class GUI extends YMLFile {
@ConfigDataString(path = "CHEST.VoteStreaks.Name")
@Getter
private String chestVoteStreaksName = "&6Vote streaks";
@ConfigDataString(path = "BOOK.VoteURLBookGUI.AlreadyVotedColor")
@Getter
private String bookVoteURLBookGUIAlreadyVotedColor = "RED";
@@ -0,0 +1,127 @@
package com.bencodez.votingplugin.core.maintenance;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Cumulative active-runtime clock for the owner-facing auto-create setup reminder.
* This class performs no persistence or notification. One owned worker must
* serialize the entire propose, persist, commit, then notify sequence, including
* reload and shutdown checkpoints. Method synchronization alone does not serialize
* the external persistence step. Only notify after a successful commit.
*
* <p>Reconstruct this state from its durable snapshot and a fresh monotonic clock
* reading on startup; never persist the process-local clock anchor. Samples must
* be less than {@link Long#MAX_VALUE} nanoseconds apart (the normal nanoTime elapsed
* time contract). Negative clock readings and signed clock wrap are supported.
*/
public final class AutoCreateVoteSitesReminderState {
public static final long NEVER_REMINDED = -1;
/** The first reminder is emitted on the first active checkpoint. */
public static final long DEFAULT_THRESHOLD_MILLIS = 0;
/** Subsequent reminders are bounded to one every 48 active hours. */
public static final long DEFAULT_REPEAT_MILLIS = TimeUnit.HOURS.toMillis(48);
public static final long MAX_INTERVAL_MILLIS = Long.MAX_VALUE / 1_000_000;
private static final long NANOS_PER_MILLI = 1_000_000;
/** Immutable durable data; a reminder marker must refer to accrued runtime. */
public record Snapshot(long totalRuntimeMillis, long lastReminderRuntimeMillis) {
public Snapshot {
if (totalRuntimeMillis < 0 || lastReminderRuntimeMillis < NEVER_REMINDED
|| lastReminderRuntimeMillis > totalRuntimeMillis) {
throw new IllegalArgumentException("Invalid reminder runtime snapshot");
}
}
}
/**
* A tentative checkpoint, valid only until this state commits any candidate.
* Persist {@link #snapshot()} before committing; discard on a failed write and
* propose again. Creating or discarding candidates changes no committed state.
*/
public static final class Candidate {
private final Object epoch;
private final Snapshot snapshot;
private final long checkpointNanos;
private final boolean reminderDue;
private Candidate(Object epoch, Snapshot snapshot, long checkpointNanos, boolean reminderDue) {
this.epoch = epoch;
this.snapshot = snapshot;
this.checkpointNanos = checkpointNanos;
this.reminderDue = reminderDue;
}
public Snapshot snapshot() {
return snapshot;
}
public boolean reminderDue() {
return reminderDue;
}
}
private Snapshot snapshot;
private long checkpointNanos;
private Object epoch = new Object();
public AutoCreateVoteSitesReminderState(Snapshot snapshot, long nowNanos) {
this.snapshot = Objects.requireNonNull(snapshot, "snapshot");
this.checkpointNanos = nowNanos;
}
public synchronized Snapshot snapshot() {
return snapshot;
}
/**
* Accrue runtime regardless of either flag. Both flags are required to propose a
* reminder, at the inclusive threshold or repeat boundary. Settings are sampled
* for each proposal; changing them does not reset accumulated runtime or markers.
* Backward samples accrue nothing and leave the clock anchor unchanged.
*/
public synchronized Candidate propose(long nowNanos, boolean reminderEnabled, boolean autoCreateEnabled,
long thresholdMillis, long repeatMillis) {
validateInterval(thresholdMillis);
validateInterval(repeatMillis);
if (repeatMillis == 0) {
throw new IllegalArgumentException("Reminder repeat interval must be positive");
}
long elapsedNanos = nowNanos - checkpointNanos;
long elapsedMillis = elapsedNanos > 0 ? elapsedNanos / NANOS_PER_MILLI : 0;
long total = saturatingAdd(snapshot.totalRuntimeMillis(), elapsedMillis);
long lastReminder = snapshot.lastReminderRuntimeMillis();
boolean due = reminderEnabled && autoCreateEnabled && total >= thresholdMillis
&& (lastReminder == NEVER_REMINDED || total - lastReminder >= repeatMillis);
Snapshot next = new Snapshot(total, due ? total : lastReminder);
// Consume only whole milliseconds so frequent checkpoints do not lose time.
long nextCheckpoint = checkpointNanos + elapsedMillis * NANOS_PER_MILLI;
return new Candidate(epoch, next, nextCheckpoint, due);
}
/**
* Publish a checkpoint only after its durable write succeeded. A successful
* commit invalidates every other candidate from that epoch, including this one.
* Foreign, stale, or duplicate candidates are rejected without changing state.
*/
public synchronized void commit(Candidate candidate) {
Objects.requireNonNull(candidate, "candidate");
if (candidate.epoch != epoch) {
throw new IllegalArgumentException("Candidate is not current for this reminder state");
}
snapshot = candidate.snapshot;
checkpointNanos = candidate.checkpointNanos;
epoch = new Object();
}
private static void validateInterval(long millis) {
if (millis < 0 || millis > MAX_INTERVAL_MILLIS) {
throw new IllegalArgumentException("Reminder interval is outside the supported range");
}
}
private static long saturatingAdd(long total, long elapsed) {
return elapsed > Long.MAX_VALUE - total ? Long.MAX_VALUE : total + elapsed;
}
}
@@ -0,0 +1,212 @@
package com.bencodez.votingplugin.maintenance;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
import java.util.logging.Logger;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Candidate;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
import com.bencodez.votingplugin.util.DurableFiles.PublishedException;
/** Owner-only console reminder; all file work runs on its bounded background worker. */
public final class AutoCreateVoteSitesReminderService {
public record Settings(boolean enabled, boolean autoCreateEnabled, long afterMillis, long repeatMillis) {
public Settings {
if (afterMillis < 0 || repeatMillis <= 0
|| afterMillis > AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS
|| repeatMillis > AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS) {
throw new IllegalArgumentException("Reminder intervals must be non-negative and bounded");
}
}
}
@FunctionalInterface
interface StoreFactory {
AutoCreateVoteSitesReminderStore create(Path directory) throws IOException;
}
private final Path directory;
private final ScheduledExecutorService executor;
private final Logger logger;
private final LongSupplier clock;
private final StoreFactory storeFactory;
private final long startedNanos;
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean recoveryRequested = new AtomicBoolean();
private record CloseRequest(CompletableFuture<Void> result, long stoppedNanos) { }
private final AtomicReference<CloseRequest> closing = new AtomicReference<>();
private final Object clockGate = new Object();
private volatile Settings settings;
private volatile ScheduledFuture<?> task;
// Accessed only inside the serialized worker checkpoint, never by gameplay callers.
private AutoCreateVoteSitesReminderStore store;
private AutoCreateVoteSitesReminderState state;
private boolean failed;
private boolean checkpointRequired;
private boolean waitingReported;
public AutoCreateVoteSitesReminderService(Path directory, ScheduledExecutorService executor,
Logger logger, Settings settings) {
this(directory, executor, logger, settings, System::nanoTime, AutoCreateVoteSitesReminderStore::new);
}
AutoCreateVoteSitesReminderService(Path directory, ScheduledExecutorService executor,
Logger logger, Settings settings, LongSupplier clock, StoreFactory storeFactory) {
this.directory = Objects.requireNonNull(directory);
this.executor = Objects.requireNonNull(executor);
this.logger = Objects.requireNonNull(logger);
this.settings = Objects.requireNonNull(settings);
this.clock = Objects.requireNonNull(clock);
this.storeFactory = Objects.requireNonNull(storeFactory);
startedNanos = clock.getAsLong();
}
public void start() {
if (closing.get() != null || !started.compareAndSet(false, true)) return;
try {
task = executor.scheduleWithFixedDelay(this::tick, 0, 60, TimeUnit.SECONDS);
if (closing.get() != null) task.cancel(false);
} catch (RuntimeException rejected) {
logger.warning("AutoCreateVoteSites setup reminder could not start; voting is unaffected.");
}
}
/** Reload is a snapshot update, never synchronous file I/O or another periodic task. */
public void reload(Settings next) {
settings = Objects.requireNonNull(next);
recoveryRequested.set(true);
}
private void tick() {
long nowNanos;
synchronized (clockGate) {
if (closing.get() != null) return;
nowNanos = clock.getAsLong();
}
try {
checkpoint(nowNanos, false);
} catch (IOException | RuntimeException failure) {
logger.warning("AutoCreateVoteSites setup reminder paused after a checkpoint failure; "
+ "repair its checkpoint if necessary and reload or restart. Voting is unaffected.");
}
}
private synchronized void checkpoint(long nowNanos, boolean finalCheckpoint) throws IOException {
CloseRequest stopped = closing.get();
if (stopped != null) {
if (!finalCheckpoint) return;
nowNanos = stopped.stoppedNanos();
}
if (failed && !recoveryRequested.getAndSet(false)) {
if (finalCheckpoint) throw new IOException("Reminder checkpoint remains unavailable");
return;
}
try {
if (store == null) store = storeFactory.create(directory);
if (!store.tryAcquireLease()) {
if (finalCheckpoint) throw new IOException("Final reminder checkpoint has no storage ownership");
if (!waitingReported) {
logger.warning("AutoCreateVoteSites setup reminder is waiting for a retiring reminder worker; "
+ "voting is unaffected.");
waitingReported = true;
}
return;
}
waitingReported = false;
if (state == null || failed) {
Snapshot saved;
try {
saved = store.load();
} catch (IOException | RuntimeException unverified) {
checkpointRequired = true;
throw unverified;
}
if (saved == null && checkpointRequired) {
throw new IOException("Reminder checkpoint was removed while running");
}
if (saved != null) checkpointRequired = true;
state = new AutoCreateVoteSitesReminderState(saved == null ? new Snapshot(0, -1) : saved,
state == null && !failed ? startedNanos : nowNanos);
failed = false;
} else if (checkpointRequired && store.load() == null) {
throw new IOException("Reminder checkpoint was removed while running");
}
recoveryRequested.set(false);
Settings current = settings;
Candidate candidate = state.propose(nowNanos, !finalCheckpoint && current.enabled(),
current.autoCreateEnabled(), current.afterMillis(), current.repeatMillis());
try {
store.save(candidate.snapshot());
} catch (PublishedException uncertain) {
checkpointRequired = true;
throw uncertain;
}
checkpointRequired = true;
state.commit(candidate);
// Do not publish a stale warning if shutdown or configuration changed during I/O.
if (candidate.reminderDue() && closing.get() == null && current.equals(settings)) {
logger.warning("After voting-site setup, set AutoCreateVoteSites: false in Config.yml.");
}
} catch (IOException | RuntimeException failure) {
failed = true;
throw failure;
}
}
/** Cancel promptly; enqueue the final checkpoint without blocking the server thread. */
public CompletableFuture<Void> closeAsync() {
CloseRequest request;
synchronized (clockGate) {
CloseRequest existing = closing.get();
if (existing != null) return existing.result();
request = new CloseRequest(new CompletableFuture<>(), clock.getAsLong());
closing.set(request);
}
CompletableFuture<Void> result = request.result();
long stoppedNanos = request.stoppedNanos();
ScheduledFuture<?> current = task;
if (current != null) current.cancel(false);
if (!started.get()) {
result.complete(null);
return result;
}
try {
executor.execute(() -> {
Exception failure = null;
try {
checkpoint(stoppedNanos, true);
} catch (IOException | RuntimeException checkpointFailure) {
failure = checkpointFailure;
} finally {
if (store != null) {
try {
store.releaseLease();
} catch (IOException | RuntimeException cleanupFailure) {
if (failure == null) failure = cleanupFailure;
else failure.addSuppressed(cleanupFailure);
}
}
}
if (failure == null) result.complete(null);
else {
logger.warning("AutoCreateVoteSites reminder checkpoint or cleanup did not complete; "
+ "inspect its storage before reloading.");
result.completeExceptionally(failure);
}
});
} catch (RuntimeException rejected) {
result.completeExceptionally(rejected);
}
return result;
}
}
@@ -0,0 +1,240 @@
package com.bencodez.votingplugin.maintenance;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.OpenOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
import com.bencodez.votingplugin.util.DurableFiles;
/**
* A fixed, checksummed active-runtime checkpoint in the plugin's existing data
* directory. One reminder worker owns all load/save operations. This store never
* creates the directory, repairs corrupt records, or follows a record symlink.
*/
public final class AutoCreateVoteSitesReminderStore {
private static final String FILE_NAME = ".auto-create-sites-reminder.dat";
private static final byte[] MAGIC = "VPACR001".getBytes(StandardCharsets.US_ASCII);
private static final int HEADER_BYTES = 24;
private static final int RECORD_BYTES = 56;
private final Path directory;
private final Path target;
private final Publisher publisher;
private FileChannel leaseChannel;
private FileLock lease;
@FunctionalInterface
interface Publisher {
void publish(Path staged, Path target) throws IOException;
}
public AutoCreateVoteSitesReminderStore(Path dataDirectory) throws IOException {
this(dataDirectory, DurableFiles::publishStagedFile);
}
AutoCreateVoteSitesReminderStore(Path dataDirectory, Publisher publisher) throws IOException {
this.directory = Objects.requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().normalize();
this.target = directory.resolve(FILE_NAME);
this.publisher = Objects.requireNonNull(publisher, "publisher");
validateDirectory();
}
/**
* Nonblocking admission across plugin instances/class loaders in one JVM, held
* through final publication. Multiple JVMs sharing the directory are unsupported:
* closing another channel may release native locks on some platforms.
*/
public boolean tryAcquireLease() throws IOException {
if (lease != null && lease.isValid()) return true;
validateDirectory();
Path lockPath = directory.resolve(".auto-create-sites-reminder.lock");
try {
BasicFileAttributes attributes = Files.readAttributes(lockPath, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() != 0) {
throw new IOException("Invalid reminder ownership file");
}
} catch (NoSuchFileException absent) {
// Create only this fixed empty metadata file inside the checked data directory.
}
Set<OpenOption> options = Set.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
FileChannel channel = Files.getFileAttributeView(directory, PosixFileAttributeView.class,
LinkOption.NOFOLLOW_LINKS) == null ? FileChannel.open(lockPath, options)
: FileChannel.open(lockPath, options,
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
boolean acquired = false;
try {
if (channel.size() != 0) throw new IOException("Invalid reminder ownership file");
FileLock candidate;
try {
candidate = channel.tryLock();
} catch (OverlappingFileLockException busy) {
return false;
}
if (candidate == null) return false;
leaseChannel = channel;
lease = candidate;
acquired = true;
return true;
} finally {
if (!acquired) channel.close();
}
}
/** Closing the channel releases its lock; keep the fixed lock file for successors. */
public void releaseLease() throws IOException {
FileChannel channel = leaseChannel;
leaseChannel = null;
lease = null;
if (channel != null) channel.close();
}
/** Returns null only when the record is absent from a valid existing directory. */
public Snapshot load() throws IOException {
validateDirectory();
try {
return readRecord(target);
} catch (NoSuchFileException absent) {
// A missing/replaced data directory is a failure, not an absent checkpoint.
validateDirectory();
if (Files.notExists(target, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
throw absent;
}
}
/**
* Publish a checked same-directory staged record using DurableFiles. Existing
* invalid or unreadable records block replacement. PublishedException propagates
* unchanged: the new record may already be visible, so no rollback is attempted.
*/
public void save(Snapshot snapshot) throws IOException {
Objects.requireNonNull(snapshot, "snapshot");
load();
Path staged = createStagedFile();
Throwable failure = null;
try {
ByteBuffer record = ByteBuffer.wrap(encode(snapshot));
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING, LinkOption.NOFOLLOW_LINKS)) {
while (record.hasRemaining()) {
if (channel.write(record) <= 0) {
throw new IOException("Could not write reminder checkpoint");
}
}
}
if (!snapshot.equals(readRecord(staged))) {
throw new IOException("Staged reminder checkpoint did not match");
}
// Recheck after staging, before replacing any existing destination.
load();
publisher.publish(staged, target);
} catch (IOException | RuntimeException | Error primary) {
failure = primary;
throw primary;
} finally {
try {
Files.deleteIfExists(staged);
} catch (IOException | RuntimeException cleanup) {
if (failure != null) {
failure.addSuppressed(cleanup);
} else {
throw new DurableFiles.PublishedException(cleanup instanceof IOException io ? io
: new IOException("Reminder staging cleanup failed after publication", cleanup));
}
}
}
}
private void validateDirectory() throws IOException {
BasicFileAttributes attributes = Files.readAttributes(directory, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
throw new IOException("Reminder data directory must be an existing directory");
}
}
private Path createStagedFile() throws IOException {
if (Files.getFileAttributeView(directory, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS) != null) {
return Files.createTempFile(directory, ".auto-create-sites-reminder-", ".tmp",
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
}
return Files.createTempFile(directory, ".auto-create-sites-reminder-", ".tmp");
}
private static Snapshot readRecord(Path recordPath) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(recordPath, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() != RECORD_BYTES) {
throw new IOException("Invalid reminder checkpoint file");
}
ByteBuffer record = ByteBuffer.allocate(RECORD_BYTES);
try (FileChannel channel = FileChannel.open(recordPath, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
if (channel.size() != RECORD_BYTES) {
throw new IOException("Invalid reminder checkpoint length");
}
while (record.hasRemaining()) {
if (channel.read(record) <= 0) {
throw new IOException("Incomplete reminder checkpoint");
}
}
if (channel.size() != RECORD_BYTES || channel.read(ByteBuffer.allocate(1)) != -1) {
throw new IOException("Trailing reminder checkpoint data");
}
}
return decode(record.array());
}
private static byte[] encode(Snapshot snapshot) {
ByteBuffer record = ByteBuffer.allocate(RECORD_BYTES).order(ByteOrder.BIG_ENDIAN);
record.put(MAGIC).putLong(snapshot.totalRuntimeMillis()).putLong(snapshot.lastReminderRuntimeMillis());
record.put(digest(Arrays.copyOf(record.array(), HEADER_BYTES)));
return record.array();
}
private static Snapshot decode(byte[] record) throws IOException {
if (!Arrays.equals(MAGIC, Arrays.copyOf(record, MAGIC.length))) {
throw new IOException("Invalid reminder checkpoint schema");
}
byte[] expected = digest(Arrays.copyOf(record, HEADER_BYTES));
if (!MessageDigest.isEqual(expected, Arrays.copyOfRange(record, HEADER_BYTES, RECORD_BYTES))) {
throw new IOException("Invalid reminder checkpoint checksum");
}
ByteBuffer header = ByteBuffer.wrap(record).order(ByteOrder.BIG_ENDIAN);
header.position(MAGIC.length);
try {
return new Snapshot(header.getLong(), header.getLong());
} catch (IllegalArgumentException invalid) {
throw new IOException("Invalid reminder checkpoint counters", invalid);
}
}
private static byte[] digest(byte[] header) {
try {
return MessageDigest.getInstance("SHA-256").digest(header);
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is unavailable", unavailable);
}
}
}
@@ -1,119 +0,0 @@
package com.bencodez.votingplugin.npc;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
/** Normal /av command handlers; no separate command map or player GUI registry. */
public final class VotingNPCCommands {
private static final String PERMISSION = "VotingPlugin.Commands.AdminVote.NPC";
private VotingNPCCommands() { }
public static void register(VotingPluginMain plugin) {
add(plugin, new String[] { "NPC", "Create", "(string)" }, false);
add(plugin, new String[] { "NPC", "Create", "(string)", "(list)" }, false);
add(plugin, new String[] { "NPC", "List" }, true);
add(plugin, new String[] { "NPC", "Inspect", "(npcid)" }, true);
add(plugin, new String[] { "NPC", "Remove", "(npcid)" }, true);
}
private static void add(VotingPluginMain plugin, String[] pattern, boolean console) {
plugin.getAdminVoteCommand().add(new CommandHandler(plugin, pattern, PERMISSION + "|VotingPlugin.Admin",
"Manage saved vanilla voting NPCs", console) {
@Override public void execute(CommandSender sender, String[] args) {
// CommandHandler invokes execute asynchronously. Capture location,
// permission and messages only after reaching the sender's owner.
String[] captured = args.clone();
onSender(plugin, sender, () -> executeOwned(plugin, sender, captured));
}
});
}
private static boolean allowed(CommandSender sender) {
return (!(sender instanceof Player player) || player.isOnline())
&& (sender.hasPermission(PERMISSION) || sender.hasPermission("VotingPlugin.Admin"));
}
private static void onSender(VotingPluginMain plugin, CommandSender sender, Runnable action) {
Player player = sender instanceof Player candidate ? candidate : null;
BukkitCompletionScheduler.run(plugin, player, action, player == null ? action : () -> { }, () -> { });
}
private static void executeOwned(VotingPluginMain plugin, CommandSender sender, String[] args) {
if (!allowed(sender)) return;
VotingNPCManager manager = plugin.getVotingNPCManager();
if (manager == null || !manager.available()) {
sender.sendMessage("Voting NPCs are disabled, unsupported, or waiting for valid storage. Check VotingNPCs.Enabled in Config.yml and reload.");
return;
}
switch (args[1].toLowerCase(java.util.Locale.ROOT)) {
case "create":
if (!(sender instanceof Player player)) { sender.sendMessage("Create a voting NPC from in game."); return; }
try {
Location at = player.getLocation();
String name = args.length > 3 ? String.join(" ", Arrays.copyOfRange(args, 3, args.length)) : "&aVote";
VotingNPCDefinition definition = new VotingNPCDefinition(args[2], UUID.randomUUID(), at.getWorld().getUID(),
at.getX(), at.getY(), at.getZ(), at.getYaw(), at.getPitch(), name);
confirm(plugin, sender, manager, manager.create(definition),
ignored -> "Saved voting NPC '" + definition.id() + "'. Its villager appears when the area is loaded and available.");
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Use a lowercase ID of 1-40 letters, digits, underscores or hyphens, and a name of 1-128 characters without controls.");
}
break;
case "remove":
confirm(plugin, sender, manager, manager.remove(args[2]), removed -> removed
? "Removed saved voting NPC '" + args[2] + "'; exact entity cleanup has been requested."
: "No voting NPC has that ID.");
break;
case "inspect":
manager.definitions().stream().filter(value -> value.id().equals(args[2])).findFirst().ifPresentOrElse(value ->
sender.sendMessage("Voting NPC " + value.id() + ": world " + value.world() + ", "
+ value.x() + ", " + value.y() + ", " + value.z() + "; " + manager.runtimeState(value.id())),
() -> sender.sendMessage("No voting NPC has that ID."));
break;
case "list":
sender.sendMessage("Saved voting NPCs: " + manager.definitions().size() + "/64; tracked runtime slots: " + manager.runtimeCount());
for (var value : manager.definitions()) sender.sendMessage(value.id() + ": " + manager.runtimeState(value.id()));
break;
default:
sender.sendMessage("Use /av npc create <id> [name], list, inspect <id>, or remove <id>.");
}
}
private static String failureMessage(VotingNPCManager manager, Throwable failure) {
while ((failure instanceof java.util.concurrent.CompletionException
|| failure instanceof java.util.concurrent.ExecutionException) && failure.getCause() != null) {
failure = failure.getCause();
}
if (manager.available()) {
if (failure instanceof VotingNPCRegistry.ValidationFailure validation) {
return switch (validation.reason) {
case DUPLICATE_ID -> "That voting NPC ID already exists. Use a different ID or remove it first.";
case DUPLICATE_IDENTITY -> "That voting NPC identity already exists. No definition was changed.";
case LIMIT -> "The 64 voting NPC limit has been reached. Remove an NPC before creating another.";
};
}
if (failure instanceof java.util.concurrent.RejectedExecutionException) {
return "Voting NPC storage is busy. Wait for pending changes to finish before retrying.";
}
}
return "Voting NPC change was not confirmed. Check storage and reload before retrying; no success is assumed.";
}
private static <T> void confirm(VotingPluginMain plugin, CommandSender sender, VotingNPCManager manager, CompletionStage<T> completion,
java.util.function.Function<T, String> message) {
completion.whenComplete((value, failure) -> onSender(plugin, sender, () -> {
if (!allowed(sender)) return;
sender.sendMessage(failure == null ? message.apply(value)
: failureMessage(manager, failure));
}));
}
}
@@ -1,29 +0,0 @@
package com.bencodez.votingplugin.npc;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
/** Durable definition; it contains no live world, player, entity or reward state. */
public record VotingNPCDefinition(String id, UUID identity, UUID world, double x, double y, double z,
float yaw, float pitch, String name) {
private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9_-]{0,39}");
public VotingNPCDefinition {
if (id == null || !ID.matcher(id).matches()) throw new IllegalArgumentException("Invalid voting NPC ID");
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(world, "world");
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| Math.abs(x) > 30_000_000 || Math.abs(z) > 30_000_000
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("Invalid voting NPC position");
}
if (name == null || name.isEmpty() || name.length() > 128
|| name.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("Invalid voting NPC name");
}
}
public int chunkX() { return Math.floorDiv((int) Math.floor(x), 16); }
public int chunkZ() { return Math.floorDiv((int) Math.floor(z), 16); }
}
@@ -1,450 +0,0 @@
package com.bencodez.votingplugin.npc;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityCombustEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityInteractEvent;
import org.bukkit.event.entity.EntityTargetEvent;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.entity.EntityTransformEvent;
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
/** Persistent definitions, transient public villagers, and no vote/reward accounting. */
public final class VotingNPCManager implements Listener, AutoCloseable {
private final VotingPluginMain plugin;
private final VotingNPCScheduler scheduler;
private final Consumer<Player> open;
private final Map<String, RuntimeNPC> resources = new ConcurrentHashMap<>();
private final Set<String> capacityDemand = ConcurrentHashMap.newKeySet();
private final AtomicBoolean capacityWakeQueued = new AtomicBoolean();
private final Map<UUID, RuntimeNPC> entities = new ConcurrentHashMap<>();
private final Set<ChunkKey> unloadingChunks = ConcurrentHashMap.newKeySet();
private final Set<UUID> unloadingWorlds = ConcurrentHashMap.newKeySet();
private final Map<UUID, Long> recentClicks = new LinkedHashMap<>();
private volatile VotingNPCRegistry registry;
private volatile boolean enabled;
private volatile boolean closed;
private volatile long configuration;
private final AtomicBoolean warned = new AtomicBoolean();
private record ChunkKey(UUID world, int x, int z) { }
private static final class RuntimeNPC {
final VotingNPCDefinition definition;
final Location anchor;
final long configuration;
boolean closed, cleanupQueued, reconstructRequested;
Villager entity;
UUID entityId;
RuntimeNPC(VotingNPCDefinition definition, World world, long configuration) {
this.definition = definition;
this.configuration = configuration;
anchor = new Location(world, definition.x(), definition.y(), definition.z(), definition.yaw(), definition.pitch());
}
}
public VotingNPCManager(VotingPluginMain plugin) {
this(plugin, new VotingNPCScheduler(plugin), null, player -> openVotingSites(plugin, player));
}
static void openVotingSites(VotingPluginMain plugin, Player player) {
// Reuse the normal command's permissions, asynchronous user loading,
// configured GUI, cooldown display, and URL handling; no root alias.
for (var handler : plugin.getVoteCommand()) {
if (handler.runCommand(player, new String[] { "URL" })) return;
}
player.sendMessage("Voting-site selection is unavailable.");
}
VotingNPCManager(VotingPluginMain plugin, VotingNPCScheduler scheduler, VotingNPCRegistry registry, Consumer<Player> open) {
this.plugin = plugin;
this.scheduler = scheduler;
this.registry = registry;
this.open = open;
}
public static boolean supported() {
try {
Entity.class.getMethod("setPersistent", boolean.class);
Entity.class.getMethod("setGravity", boolean.class);
Entity.class.getMethod("setInvulnerable", boolean.class);
LivingEntity.class.getMethod("setAI", boolean.class);
LivingEntity.class.getMethod("setCollidable", boolean.class);
World.class.getMethod("spawn", Location.class, Class.class, Consumer.class);
return true;
} catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
}
/** Configuration callers do no file I/O and do not wait for storage. */
public void configure(boolean requested) {
VotingNPCRegistry owner;
CompletionStage<List<VotingNPCDefinition>> loading;
long generation;
boolean activate;
synchronized (this) {
if (closed) return;
generation = ++configuration;
activate = requested && supported();
enabled = activate;
if (!activate) { owner = null; loading = null; }
else {
if (registry == null) {
registry = new VotingNPCRegistry(plugin.getDataFolder().toPath().resolve("VotingNPCs.json"));
loading = registry.initialLoad();
} else loading = registry.reload();
owner = registry;
}
}
if (!activate) {
for (RuntimeNPC npc : resources.values()) retire(npc);
if (requested) report(new IllegalStateException("Vanilla voting NPC API is unavailable"));
return;
}
loading.whenComplete((definitions, failure) -> {
synchronized (VotingNPCManager.this) {
if (closed || !enabled || generation != configuration || registry != owner) return;
}
if (failure != null) {
for (RuntimeNPC npc : resources.values()) retire(npc);
report(failure);
} else {
warned.set(false);
reconcile();
}
});
}
public boolean available() {
VotingNPCRegistry owner = registry;
return !closed && enabled && owner != null && owner.available();
}
public List<VotingNPCDefinition> definitions() {
VotingNPCRegistry owner = registry;
return owner == null ? List.of() : owner.snapshot();
}
/** Returns durable definition completion; runtime creation can still await a loaded chunk. */
public CompletionStage<VotingNPCDefinition> create(VotingNPCDefinition definition) {
VotingNPCRegistry owner = registry;
if (!available()) return CompletableFuture.failedFuture(new IllegalStateException("Enable VotingNPCs and load valid storage first"));
CompletionStage<VotingNPCDefinition> completion = owner.create(definition);
completion.whenComplete((value, failure) -> mutationFinished(owner, failure));
return completion;
}
public CompletionStage<Boolean> remove(String id) {
VotingNPCRegistry owner = registry;
if (!available()) return CompletableFuture.failedFuture(new IllegalStateException("Enable VotingNPCs and load valid storage first"));
CompletionStage<Boolean> completion = owner.remove(id);
completion.whenComplete((value, failure) -> mutationFinished(owner, failure));
return completion;
}
private void mutationFinished(VotingNPCRegistry owner, Throwable failure) {
if (owner != registry || closed) return;
if (failure == null) reconcile();
else if (!owner.available()) {
for (RuntimeNPC npc : resources.values()) retire(npc);
report(failure);
}
}
private boolean current(RuntimeNPC npc) {
return available() && definitions().contains(npc.definition);
}
private void reconcile() {
retryPendingCleanup();
capacityDemand.retainAll(definitions().stream().map(VotingNPCDefinition::id).toList());
List<VotingNPCDefinition> snapshot = definitions();
Set<ChunkKey> chunks = new HashSet<>();
Set<UUID> worlds = new HashSet<>();
for (var value : snapshot) { chunks.addAll(occupiedChunks(value)); worlds.add(value.world()); }
unloadingChunks.retainAll(chunks);
unloadingWorlds.retainAll(worlds);
for (RuntimeNPC npc : resources.values()) if (!current(npc)) retire(npc);
if (!available()) return;
scheduler.global(() -> {
if (!available()) return;
for (var value : definitions()) ensure(value);
}, this::report);
}
/** Same conservative half-block bounds used for creation ownership and chunk availability. */
private static Set<ChunkKey> occupiedChunks(VotingNPCDefinition value) {
Set<ChunkKey> chunks = new HashSet<>();
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 }) {
chunks.add(new ChunkKey(value.world(), (int) Math.floor(value.x() + dx) >> 4,
(int) Math.floor(value.z() + dz) >> 4));
}
return chunks;
}
private boolean unloading(VotingNPCDefinition value) {
return occupiedChunks(value).stream().anyMatch(unloadingChunks::contains);
}
private void ensure(VotingNPCDefinition value) {
if (!available() || !definitions().contains(value) || unloadingWorlds.contains(value.world()) || unloading(value)) return;
World world = Bukkit.getWorld(value.world());
if (world == null) return;
RuntimeNPC npc = new RuntimeNPC(value, world, configuration);
RuntimeNPC previous;
synchronized (resources) {
previous = resources.get(value.id());
if (previous == null) {
if (resources.size() >= VotingNPCStore.MAX_NPCS) {
if (capacityDemand.size() < VotingNPCStore.MAX_NPCS) capacityDemand.add(value.id());
return;
}
resources.put(value.id(), npc);
capacityDemand.remove(value.id());
}
}
if (previous != null) {
synchronized (previous) { if (previous.closed) previous.reconstructRequested = true; }
return;
}
scheduler.region(npc.anchor, () -> spawn(npc), failure -> { retire(npc); report(failure); });
}
private boolean loadedArea(RuntimeNPC npc) {
World world = npc.anchor.getWorld();
if (Bukkit.getWorld(npc.definition.world()) != world || unloadingWorlds.contains(npc.definition.world())
|| unloading(npc.definition)) return false;
for (double dx : new double[] { -.5, .5 }) for (double dz : new double[] { -.5, .5 }) {
Location edge = npc.anchor.clone().add(dx, 0, dz);
if (!scheduler.owns(edge) || !world.isChunkLoaded(edge.getBlockX() >> 4, edge.getBlockZ() >> 4)) return false;
}
return true;
}
private void spawn(RuntimeNPC npc) {
try {
synchronized (npc) {
if (npc.closed || !current(npc) || !loadedArea(npc)) {
boolean capacityReleased = !npc.closed && !current(npc);
forget(npc, capacityReleased);
return;
}
if (npc.entity != null) return;
Villager spawned = npc.anchor.getWorld().spawn(npc.anchor, Villager.class, villager -> {
npc.entity = villager;
npc.entityId = villager.getUniqueId();
entities.put(npc.entityId, npc);
villager.setPersistent(false);
villager.setAI(false);
villager.setGravity(false);
villager.setInvulnerable(true);
villager.setSilent(true);
villager.setCollidable(false);
villager.setCanPickupItems(false);
villager.setRemoveWhenFarAway(false);
villager.setCustomName(ChatColor.translateAlternateColorCodes('&', npc.definition.name()));
villager.setCustomNameVisible(true);
});
if (spawned != npc.entity || !spawned.isValid() || !current(npc) || npc.closed) {
throw new IllegalStateException("Voting NPC spawn was cancelled or superseded");
}
}
} catch (RuntimeException | LinkageError failure) { retire(npc); report(failure); }
}
private void retire(RuntimeNPC npc) {
Villager entity;
synchronized (npc) {
npc.closed = true;
entity = npc.entity;
if (entity == null) { forget(npc, false); return; }
if (npc.cleanupQueued) return;
npc.cleanupQueued = true;
}
scheduler.entity(entity, () -> {
try { entity.remove(); forget(npc); }
catch (RuntimeException | LinkageError failure) { cleanupFailed(npc, failure); }
}, () -> forget(npc), failure -> cleanupFailed(npc, failure));
}
private void cleanupFailed(RuntimeNPC npc, Throwable failure) {
synchronized (npc) {
npc.cleanupQueued = false;
npc.reconstructRequested = true;
}
// Keep the exact identity and capacity reservation. Rejected scheduling
// is not successful removal and must not permit duplicate reconstruction.
report(failure);
}
private void retryPendingCleanup() {
for (RuntimeNPC npc : resources.values()) {
synchronized (npc) {
if (!npc.closed || !npc.reconstructRequested || npc.cleanupQueued || npc.entity == null) continue;
}
retire(npc);
}
}
private void forget(RuntimeNPC npc) { forget(npc, false); }
private void forget(RuntimeNPC npc, boolean capacityReleased) {
boolean reconstruct, removed;
synchronized (npc) {
reconstruct = npc.entity != null && (npc.reconstructRequested || npc.configuration != configuration
|| definitions().stream().anyMatch(value -> value.id().equals(npc.definition.id()) && !value.equals(npc.definition)));
if (npc.entityId != null) entities.remove(npc.entityId, npc);
npc.entity = null;
removed = resources.remove(npc.definition.id(), npc);
}
// A successor sharing this configured ID must wait for exact predecessor
// cleanup. Missing/unloaded spawn attempts do not recursively retry.
if (reconstruct && available()) scheduler.global(() -> {
for (var value : definitions()) if (value.id().equals(npc.definition.id())) ensure(value);
}, this::report);
if (removed && !capacityDemand.isEmpty() && available()
&& capacityWakeQueued.compareAndSet(false, true)) {
scheduler.global(() -> {
capacityWakeQueued.set(false);
for (var value : definitions()) if (capacityDemand.contains(value.id())) ensure(value);
}, failure -> {
capacityWakeQueued.set(false);
report(failure);
});
}
}
private void report(Throwable failure) {
if (warned.compareAndSet(false, true)) {
try {
plugin.getLogger().warning("Voting NPCs could not complete an operation; definitions are retained. Check API support and VotingNPCs.json, then reload. Cause: "
+ failure.getClass().getSimpleName());
} catch (RuntimeException ignored) { /* Reporting must not prevent independent cleanup. */ }
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interact(PlayerInteractEntityEvent event) {
RuntimeNPC npc = entities.get(event.getRightClicked().getUniqueId());
if (npc == null) return;
boolean cancelled = event.isCancelled();
event.setCancelled(true); // Only this exact owned entity; never trading.
if (cancelled || event.getHand() != EquipmentSlot.HAND || !current(npc)) return;
synchronized (npc) { if (npc.closed) { retire(npc); return; } }
Player player = event.getPlayer();
long now = System.nanoTime();
synchronized (recentClicks) {
recentClicks.entrySet().removeIf(entry -> now - entry.getValue() >= 300_000_000L);
if (recentClicks.containsKey(player.getUniqueId()) || recentClicks.size() >= 256) return;
recentClicks.put(player.getUniqueId(), now);
}
BukkitCompletionScheduler.run(plugin, player, () -> {
if (!player.isOnline() || !current(npc) || !player.getWorld().getUID().equals(npc.definition.world())
|| player.getLocation().distanceSquared(npc.anchor) > 36) return;
synchronized (npc) { if (npc.closed) return; }
open.accept(player);
}, () -> { }, () -> { });
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
private boolean owned(Entity entity) { return entity != null && entities.containsKey(entity.getUniqueId()); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void damage(EntityDamageEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void combust(EntityCombustEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void teleport(EntityTeleportEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void transform(EntityTransformEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void target(EntityTargetEvent event) { if (owned(event.getEntity()) || owned(event.getTarget())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void blockInteract(EntityInteractEvent event) { if (owned(event.getEntity())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void vehicle(VehicleEnterEvent event) { if (owned(event.getEntered())) event.setCancelled(true); }
@EventHandler public void quit(PlayerQuitEvent event) { synchronized (recentClicks) { recentClicks.remove(event.getPlayer().getUniqueId()); } }
@EventHandler(priority = EventPriority.MONITOR)
public void chunkLoad(ChunkLoadEvent event) {
UUID world = event.getWorld().getUID();
unloadingChunks.remove(new ChunkKey(world, event.getChunk().getX(), event.getChunk().getZ()));
retryPendingCleanup();
ChunkKey chunk = new ChunkKey(world, event.getChunk().getX(), event.getChunk().getZ());
for (var value : definitions()) if (occupiedChunks(value).contains(chunk)) ensure(value);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void chunkUnload(ChunkUnloadEvent event) {
ChunkKey chunk = new ChunkKey(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ());
if (definitions().stream().noneMatch(value -> occupiedChunks(value).contains(chunk))) return;
unloadingChunks.add(chunk);
for (RuntimeNPC npc : resources.values()) if (occupiedChunks(npc.definition).contains(chunk)) retire(npc);
}
@EventHandler(priority = EventPriority.MONITOR)
public void worldLoad(WorldLoadEvent event) {
UUID world = event.getWorld().getUID();
if (definitions().stream().noneMatch(value -> value.world().equals(world))) return;
unloadingWorlds.remove(world);
reconcile();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void worldUnload(WorldUnloadEvent event) {
UUID world = event.getWorld().getUID();
if (definitions().stream().noneMatch(value -> value.world().equals(world))) return;
unloadingWorlds.add(world);
for (RuntimeNPC npc : resources.values()) if (npc.definition.world().equals(world)) retire(npc);
}
public int runtimeCount() { return resources.size(); }
public String runtimeState(String id) {
RuntimeNPC npc = resources.get(id);
if (npc == null) return "waiting for a loaded, available area";
synchronized (npc) {
if (npc.closed) return "cleanup pending";
return npc.entity == null ? "creation scheduled" : "villager created";
}
}
@Override public void close() {
synchronized (this) { if (closed) return; closed = true; enabled = false; ++configuration; }
VotingNPCRegistry owner = registry;
if (owner != null) owner.close();
for (RuntimeNPC npc : new ArrayList<>(resources.values())) retire(npc);
synchronized (recentClicks) { recentClicks.clear(); }
}
}
@@ -1,206 +0,0 @@
package com.bencodez.votingplugin.npc;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
/**
* One bounded, daemon file owner. Completion means the definition was persisted,
* not that a world entity has spawned. Callbacks have no player/region ownership.
*/
final class VotingNPCRegistry implements AutoCloseable {
enum Rejection { DUPLICATE_ID, DUPLICATE_IDENTITY, LIMIT }
static final class ValidationFailure extends IllegalArgumentException {
private static final long serialVersionUID = 1L;
final Rejection reason;
ValidationFailure(Rejection reason) { super("Voting NPC request rejected"); this.reason = reason; }
}
private final Object admission = new Object();
private final ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(128);
private final CountDownLatch stopped = new CountDownLatch(1);
private final VotingNPCStore store;
private final Path lockPath;
private final CompletableFuture<List<VotingNPCDefinition>> initial;
private volatile Map<String, VotingNPCDefinition> definitions = Map.of();
private volatile boolean available;
private volatile boolean closed;
// Only the file worker accesses these resources.
private FileChannel lockChannel;
private FileLock lease;
private boolean storageReady;
VotingNPCRegistry(Path file) {
this(file, new VotingNPCStore(file));
}
VotingNPCRegistry(Path file, VotingNPCStore store) {
this.store = store;
lockPath = file.toAbsolutePath().normalize().resolveSibling(file.getFileName() + ".lock");
initial = submit(false, this::readDefinitions);
Thread thread = new Thread(this::work, "VotingPlugin-NPC-storage");
thread.setDaemon(true);
try { thread.start(); }
catch (RuntimeException | Error failure) {
closed = true;
initial.completeExceptionally(failure);
stopped.countDown();
throw failure;
}
}
CompletionStage<List<VotingNPCDefinition>> initialLoad() { return initial; }
boolean available() { return !closed && available; }
List<VotingNPCDefinition> snapshot() { return List.copyOf(definitions.values()); }
CompletionStage<List<VotingNPCDefinition>> reload() {
synchronized (admission) {
if (closed) return rejected("Voting NPC registry is closed");
var completion = submit(false, this::readDefinitions);
// A rejected reload changes nothing. The worker publishes readiness
// under this same short lock, after admission has finished.
if (!completion.isCompletedExceptionally()) available = false;
return completion;
}
}
CompletionStage<VotingNPCDefinition> create(VotingNPCDefinition definition) {
return submit(true, () -> {
if (definition == null) throw new IllegalArgumentException("Voting NPC definition is required");
if (definitions.containsKey(definition.id())) throw new ValidationFailure(Rejection.DUPLICATE_ID);
if (definitions.values().stream().anyMatch(value -> value.identity().equals(definition.identity()))) {
throw new ValidationFailure(Rejection.DUPLICATE_IDENTITY);
}
if (definitions.size() >= VotingNPCStore.MAX_NPCS) throw new ValidationFailure(Rejection.LIMIT);
var next = new LinkedHashMap<>(definitions);
next.put(definition.id(), definition);
publish(next);
return definition;
});
}
CompletionStage<Boolean> remove(String id) {
return submit(true, () -> {
if (!definitions.containsKey(id)) return false;
var next = new LinkedHashMap<>(definitions);
next.remove(id);
publish(next);
return true;
});
}
private List<VotingNPCDefinition> readDefinitions() throws IOException {
try {
ownFile();
List<VotingNPCDefinition> loaded = store.load();
var next = new LinkedHashMap<String, VotingNPCDefinition>();
for (VotingNPCDefinition value : loaded) next.put(value.id(), value);
definitions = java.util.Collections.unmodifiableMap(next);
storageReady = true;
synchronized (admission) { available = true; }
return loaded;
} catch (IOException | RuntimeException failure) {
storageReady = false;
synchronized (admission) { available = false; }
throw failure;
}
}
private void publish(LinkedHashMap<String, VotingNPCDefinition> next) throws IOException {
// The worker holds the process-independent lease through shutdown drain.
try { store.save(List.copyOf(next.values())); }
catch (IOException | RuntimeException | Error failure) {
// A failed write may already be visible. Neither later admitted
// mutations nor a new request may overwrite it from the old map.
storageReady = false;
synchronized (admission) { available = false; }
throw failure;
}
definitions = java.util.Collections.unmodifiableMap(next);
}
private <T> CompletableFuture<T> submit(boolean needsReady, Callable<T> action) {
synchronized (admission) {
if (closed || (needsReady && !available)) return rejected("Voting NPC storage is not available");
var completion = new CompletableFuture<T>();
Runnable task = () -> {
try {
if (needsReady && !storageReady) throw new RejectedExecutionException("Reload valid voting NPC storage before editing");
completion.complete(action.call());
}
catch (Throwable failure) { completion.completeExceptionally(failure); }
};
if (!queue.offer(task)) completion.completeExceptionally(new RejectedExecutionException("Voting NPC storage queue is full"));
return completion;
}
}
private static <T> CompletableFuture<T> rejected(String message) {
return CompletableFuture.failedFuture(new RejectedExecutionException(message));
}
private void ownFile() throws IOException {
if (lease != null && lease.isValid()) return;
if (!Files.isDirectory(lockPath.getParent().toRealPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Voting NPC data directory is unavailable");
}
FileChannel candidate = FileChannel.open(lockPath, StandardOpenOption.CREATE,
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
try {
FileLock acquired;
try { acquired = candidate.tryLock(); }
catch (OverlappingFileLockException overlap) { throw new IOException("Voting NPC file still has an active owner", overlap); }
if (acquired == null) throw new IOException("Voting NPC file still has an active owner");
lockChannel = candidate;
lease = acquired;
} finally {
if (lockChannel != candidate) candidate.close();
}
}
private void work() {
try {
while (!closed || !queue.isEmpty()) {
Runnable task;
try { task = queue.take(); }
catch (InterruptedException interrupted) { continue; }
task.run();
}
} finally {
available = false;
try {
if (lockChannel != null) lockChannel.close();
} catch (IOException ignored) {
// Closing this channel releases its OS lease; never do this I/O
// under an admission or executor lock on a gameplay caller.
} finally { stopped.countDown(); }
}
}
/** Stops admission without blocking or interrupting an accepted file write. */
@Override public void close() {
synchronized (admission) {
if (closed) return;
closed = true;
queue.offer(() -> { }); // Wake an idle worker; a full queue already wakes it.
}
}
/** Test/owner-worker observation only; gameplay callers must never wait here. */
boolean awaitClosed(long time, TimeUnit unit) throws InterruptedException { return stopped.await(time, unit); }
}
@@ -1,77 +0,0 @@
package com.bencodez.votingplugin.npc;
import java.lang.reflect.Method;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
/** World creation uses a region; subsequent cleanup follows the actual entity. */
class VotingNPCScheduler {
private final VotingPluginMain plugin;
private final Method locationOwner;
private final Method entityOwner;
VotingNPCScheduler(VotingPluginMain plugin) {
this.plugin = plugin;
Method location = null, entity = null;
try {
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
location = Bukkit.class.getMethod("isOwnedByCurrentRegion", Location.class);
entity = Bukkit.class.getMethod("isOwnedByCurrentRegion", Entity.class);
} catch (ClassNotFoundException ordinaryServer) {
// Spigot/Paper have a single world-owner thread.
} catch (ReflectiveOperationException unavailable) {
throw new IllegalStateException("Voting NPC region ownership API is unavailable", unavailable);
}
locationOwner = location;
entityOwner = entity;
}
boolean owns(Location location) { return owns(locationOwner, location); }
boolean owns(Entity entity) { return owns(entityOwner, entity); }
private boolean owns(Method method, Object value) {
if (method == null) return Bukkit.isPrimaryThread();
try { return Boolean.TRUE.equals(method.invoke(null, value)); }
catch (ReflectiveOperationException failure) { throw new IllegalStateException("Cannot verify voting NPC ownership", failure); }
}
void global(Runnable action, Consumer<Throwable> failure) {
try {
plugin.getBukkitScheduler().getFoliaLib().getImpl().runNextTick(ignored -> action.run())
.whenComplete((ignored, failed) -> { if (failed != null) failure.accept(failed); });
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
}
void region(Location anchor, Runnable action, Consumer<Throwable> failure) {
try {
if (owns(anchor)) action.run();
else plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(anchor, ignored -> action.run())
.whenComplete((ignored, failed) -> { if (failed != null) failure.accept(failed); });
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
}
void entity(Entity entity, Runnable action, Runnable retired, Consumer<Throwable> failure) {
AtomicBoolean executed = new AtomicBoolean();
Runnable run = () -> { if (executed.compareAndSet(false, true)) action.run(); };
Runnable gone = () -> { if (executed.compareAndSet(false, true)) retired.run(); };
try {
if (owns(entity)) { run.run(); return; }
plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(entity,
ignored -> run.run(), gone).whenComplete((status, failed) -> {
if (failed != null) failure.accept(failed);
else if (status instanceof Enum<?> result && "ENTITY_RETIRED".equals(result.name())) gone.run();
else if (!BukkitCompletionScheduler.isSuccessfulEntityTaskResult(status)) {
// Scheduler retirement does not prove the entity was removed.
failure.accept(new IllegalStateException("Voting NPC entity scheduler is unavailable"));
}
});
} catch (RuntimeException | LinkageError rejected) { failure.accept(rejected); }
}
}
@@ -1,199 +0,0 @@
package com.bencodez.votingplugin.npc;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import com.bencodez.votingplugin.util.DurableFiles;
/** Single storage-worker owner. Never call this file boundary on a gameplay thread. */
class VotingNPCStore {
static final int MAX_NPCS = 64;
private static final int MAX_BYTES = 128 * 1024;
private static final Set<String> FIELDS = Set.of("id", "identity", "world", "x", "y", "z", "yaw", "pitch", "name");
private final Path file;
private boolean initialized;
private boolean uncertain;
VotingNPCStore(Path file) { this.file = file.toAbsolutePath().normalize(); }
synchronized List<VotingNPCDefinition> load() throws IOException {
initialized = false;
checkParent();
BasicFileAttributes attributes;
try { attributes = Files.readAttributes(file, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
catch (NoSuchFileException missing) {
if (uncertain) throw new IOException("Uncertain voting NPC definitions are missing", missing);
initialized = true;
return List.of();
}
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() > MAX_BYTES) {
throw new IOException("Voting NPC definitions are not a bounded regular file");
}
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
bytes = input.readNBytes(MAX_BYTES + 1);
}
if (bytes.length > MAX_BYTES) throw new IOException("Voting NPC definitions exceed limit");
String text;
try { text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); }
catch (CharacterCodingException invalid) { throw new IOException("Invalid voting NPC encoding", invalid); }
List<VotingNPCDefinition> result = decode(text);
uncertain = false;
initialized = true;
return result;
}
synchronized void save(List<VotingNPCDefinition> definitions) throws IOException {
if (!initialized || uncertain) throw new IOException("Load valid voting NPC definitions before editing");
validate(definitions);
byte[] bytes = encode(definitions).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Voting NPC definitions exceed limit");
checkParent();
if (Files.isSymbolicLink(file)) throw new IOException("Voting NPC definitions cannot be a symbolic link");
Path staged = Files.createTempFile(file.getParent(), ".voting-npcs-", ".tmp");
boolean published = false;
Throwable primary = null;
try {
try (FileChannel output = FileChannel.open(staged, StandardOpenOption.WRITE)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(staged, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
published = true;
forceDirectory(file.getParent());
} catch (IOException | RuntimeException | Error failure) {
// A visible replacement followed by a failed directory sync is not a
// rollback. Fence later mutations rather than overwrite it from old state.
if (published) uncertain = true;
primary = failure;
throw failure;
} finally {
try { Files.deleteIfExists(staged); }
catch (IOException cleanupFailure) {
if (primary != null) primary.addSuppressed(cleanupFailure);
else {
if (published) uncertain = true;
throw cleanupFailure;
}
}
}
}
private void checkParent() throws IOException {
// Operators may place the data directory below a directory alias. Only
// the definitions and lease files themselves must remain non-links.
if (!Files.isDirectory(file.getParent().toRealPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Voting NPC data directory is unavailable");
}
}
/** Kept as a narrow I/O seam so failure-after-publication can be tested. */
void forceDirectory(Path directory) throws IOException {
DurableFiles.forceDirectory(directory);
}
private static void validate(List<VotingNPCDefinition> values) throws IOException {
if (values == null || values.size() > MAX_NPCS) throw new IOException("Voting NPC definition limit exceeded");
Set<String> ids = new HashSet<>();
Set<UUID> identities = new HashSet<>();
for (VotingNPCDefinition value : values) {
if (value == null || !ids.add(value.id()) || !identities.add(value.identity())) {
throw new IOException("Duplicate or missing voting NPC identity");
}
}
}
private static String encode(List<VotingNPCDefinition> values) throws IOException {
var text = new java.io.StringWriter();
try (JsonWriter writer = new JsonWriter(text)) {
writer.setIndent(" ");
writer.beginObject().name("version").value(1).name("npcs").beginArray();
for (VotingNPCDefinition value : values) {
writer.beginObject().name("id").value(value.id()).name("identity").value(value.identity().toString())
.name("world").value(value.world().toString()).name("x").value(value.x()).name("y").value(value.y())
.name("z").value(value.z()).name("yaw").value(value.yaw()).name("pitch").value(value.pitch())
.name("name").value(value.name()).endObject();
}
writer.endArray().endObject();
}
return text.toString() + "\n";
}
private static List<VotingNPCDefinition> decode(String text) throws IOException {
try (JsonReader reader = new JsonReader(new StringReader(text))) {
Set<String> keys = new HashSet<>();
List<VotingNPCDefinition> values = null;
reader.beginObject();
while (reader.hasNext()) {
String key = reader.nextName();
if (!keys.add(key)) throw new IOException("Duplicate voting NPC document field");
if (key.equals("version")) {
if (reader.peek() != JsonToken.NUMBER || !reader.nextString().equals("1")) {
throw new IOException("Unsupported voting NPC document version");
}
} else if (key.equals("npcs")) {
values = new ArrayList<>();
reader.beginArray();
while (reader.hasNext()) {
if (values.size() >= MAX_NPCS) throw new IOException("Voting NPC definition limit exceeded");
values.add(readDefinition(reader));
}
reader.endArray();
} else throw new IOException("Unknown voting NPC document field");
}
reader.endObject();
if (!keys.equals(Set.of("version", "npcs")) || reader.peek() != JsonToken.END_DOCUMENT) {
throw new IOException("Incomplete or trailing voting NPC document");
}
validate(values);
return List.copyOf(values);
} catch (IllegalArgumentException | IllegalStateException invalid) {
throw new IOException("Invalid voting NPC definitions", invalid);
}
}
private static VotingNPCDefinition readDefinition(JsonReader reader) throws IOException {
var fields = new java.util.HashMap<String, String>();
reader.beginObject();
while (reader.hasNext()) {
String field = reader.nextName();
if (!FIELDS.contains(field) || fields.containsKey(field)) throw new IOException("Unknown or duplicate voting NPC field");
boolean numeric = Set.of("x", "y", "z", "yaw", "pitch").contains(field);
if (reader.peek() != (numeric ? JsonToken.NUMBER : JsonToken.STRING)) throw new IOException("Invalid voting NPC field type");
fields.put(field, reader.nextString());
}
reader.endObject();
if (!fields.keySet().equals(FIELDS)) throw new IOException("Missing voting NPC field");
return new VotingNPCDefinition(fields.get("id"), uuid(fields.get("identity")), uuid(fields.get("world")),
Double.parseDouble(fields.get("x")), Double.parseDouble(fields.get("y")), Double.parseDouble(fields.get("z")),
Float.parseFloat(fields.get("yaw")), Float.parseFloat(fields.get("pitch")), fields.get("name"));
}
private static UUID uuid(String text) {
UUID value = UUID.fromString(text);
if (!value.toString().equals(text)) throw new IllegalArgumentException("Noncanonical voting NPC identity");
return value;
}
}
@@ -40,6 +40,7 @@ public class VoteStreakHandler {
private final Map<String, VoteStreakDefinition> byId = new LinkedHashMap<>();
private final Map<String, VoteStreakDefinition> byProgressGroup = new LinkedHashMap<>();
private final List<VoteStreakDefinition> ordered = new ArrayList<>();
private volatile List<VoteStreakDefinition> displayDefinitions = List.of();
public VoteStreakHandler(VotingPluginMain plugin) {
this.plugin = plugin;
@@ -56,6 +57,26 @@ public class VoteStreakHandler {
// VoteStreak definitions live in specialrewards.yml now (Option B)
ConfigurationSection root = plugin.getSpecialRewardsConfig().getData();
new VoteStreakConfigLoader().load(root);
displayDefinitions = ordered.stream().filter(VoteStreakDefinition::isEnabled).toList();
}
/** Worker-only, observational view. Does not advance periods, save state or execute rewards. */
public com.bencodez.votingplugin.streaks.VoteStreaksSnapshot snapshotForDisplay(VotingPluginUser user) {
var definitions = displayDefinitions;
var periods = List.of(
new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.DAILY, user.getDayVoteStreak(), user.getBestDayVoteStreak()),
new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.WEEKLY, user.getWeekVoteStreak(), user.getBestWeekVoteStreak()),
new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Period(VoteStreakType.MONTHLY, user.getMonthVoteStreak(), user.getBestMonthVoteStreak()));
var states = new LinkedHashMap<String, StreakState>();
var rows = new ArrayList<com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Milestone>();
for (var definition : definitions) {
if (rows.size() >= com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.MAX_MILESTONES) break;
var state = states.computeIfAbsent(getColumnName(definition), ignored -> readState(user, definition));
String label = definition.getId().codePoints().limit(80).collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString();
rows.add(new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot.Milestone(label, definition.getType(), state.streakCount,
state.bestStreakCount, definition.getRequiredAmount(), definition.getVotesRequired(), definition.isRecurring()));
}
return new com.bencodez.votingplugin.streaks.VoteStreaksSnapshot(periods, rows, definitions.size() > rows.size());
}
public List<VoteStreakDefinition> getDefinitions() {
@@ -0,0 +1,20 @@
package com.bencodez.votingplugin.streaks;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.votingplugin.VotingPluginMain;
public final class VoteStreaksCommand {
private VoteStreaksCommand() { }
public static void register(VotingPluginMain plugin) {
plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Streaks" },
VoteStreaksMenu.PERMISSION + "|VotingPlugin.Player", "View your vote streak progression", false) {
@Override public void execute(CommandSender sender, String[] args) {
if (sender instanceof Player player && plugin.getVoteStreaksMenu() != null) {
plugin.getVoteStreaksMenu().open(player);
}
}
});
}
}
@@ -0,0 +1,277 @@
package com.bencodez.votingplugin.streaks;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.BiFunction;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import com.bencodez.simpleapi.messages.MessageAPI;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
/** One isolated native inventory view; no registrations in existing production GUI managers. */
public final class VoteStreaksMenu implements Listener, AutoCloseable {
public static final String PERMISSION = "VotingPlugin.Commands.Vote.Streaks";
private static final int MAX_SESSIONS = 128, MAX_LOADS = 16;
private final VotingPluginMain plugin;
private final Consumer<Runnable> worker;
private final BiFunction<UUID, String, VoteStreaksSnapshot> snapshot;
private final Map<UUID, Session> sessions = new ConcurrentHashMap<>();
private final AtomicInteger loading = new AtomicInteger();
private final AtomicInteger reloadDepth = new AtomicInteger();
private volatile boolean closed;
public VoteStreaksMenu(VotingPluginMain plugin) {
this(plugin, task -> plugin.getUserManager().getDataManager().getTimer().execute(task), (uuid, name) ->
plugin.getVoteStreakHandler().snapshotForDisplay(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, name)));
}
VoteStreaksMenu(VotingPluginMain plugin, Consumer<Runnable> worker, BiFunction<UUID, String, VoteStreaksSnapshot> snapshot) {
this.plugin = plugin; this.worker = worker; this.snapshot = snapshot;
}
private static final class Session {
final Player player;
final UUID owner;
final String name;
final Inventory previous;
final boolean ownsPrevious;
final long created = System.nanoTime();
volatile Holder view;
Session(Player player, VoteStreaksMenu manager) {
this.player = player; owner = player.getUniqueId(); name = player.getName();
previous = player.getOpenInventory().getTopInventory();
Holder holder = manager.holder(previous);
ownsPrevious = holder != null && holder.session.owner.equals(owner);
}
}
static final class Holder implements InventoryHolder {
final VoteStreaksMenu manager;
final Session session;
final VoteStreaksSnapshot snapshot;
final int page;
Inventory inventory;
Holder(VoteStreaksMenu manager, Session session, VoteStreaksSnapshot snapshot, int page) {
this.manager = manager; this.session = session; this.snapshot = snapshot; this.page = snapshot.clampPage(page);
}
@Override public Inventory getInventory() { return inventory; }
}
private static boolean permitted(Player player) {
return player.isOnline() && (player.hasPermission(PERMISSION) || player.hasPermission("VotingPlugin.Player"));
}
private void onPlayer(Player player, Runnable work, Runnable rejected) {
BukkitCompletionScheduler.run(plugin, player, work, rejected, rejected);
}
public void open(Player player) {
onPlayer(player, () -> begin(player), () -> { });
}
private void begin(Player player) {
if (closed || reloadDepth.get() != 0 || !permitted(player)) return;
if (loading.incrementAndGet() > MAX_LOADS) {
loading.decrementAndGet(); player.sendMessage("Vote streaks are busy. Please try again shortly."); return;
}
Session session;
synchronized (sessions) {
if (closed || reloadDepth.get() != 0 || (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(player.getUniqueId()))) {
loading.decrementAndGet(); player.sendMessage("Vote streaks are unavailable. Please try again shortly."); return;
}
session = new Session(player, this); sessions.put(session.owner, session);
}
try {
worker.accept(() -> {
try {
if (!current(session)) return;
VoteStreaksSnapshot data = snapshot.apply(session.owner, session.name);
onPlayer(player, () -> {
if (!current(session)) return;
if (!permitted(player) || System.nanoTime() - session.created > 30_000_000_000L
|| player.getOpenInventory().getTopInventory() != session.previous) {
retire(session); return;
}
renderSafely(session, data, 0);
}, () -> retire(session));
} catch (RuntimeException failure) {
onPlayer(player, () -> {
if (retire(session) && player.isOnline())
player.sendMessage("Unable to read vote streaks. Please try again later.");
}, () -> retire(session));
} finally { loading.decrementAndGet(); }
});
} catch (RuntimeException rejected) {
loading.decrementAndGet(); retire(session);
player.sendMessage("Vote streaks are unavailable. Please try again later.");
}
}
private boolean current(Session session) { return !closed && sessions.get(session.owner) == session; }
private Holder holder(Inventory inventory) {
return inventory != null && inventory.getHolder() instanceof Holder holder && holder.manager == this ? holder : null;
}
private void renderSafely(Session session, VoteStreaksSnapshot data, int page) {
try { render(session, data, page); }
catch (RuntimeException failure) {
if (sessions.remove(session.owner, session)) {
closeView(session);
if (session.player.isOnline()) session.player.sendMessage("Unable to open vote streaks. Please try again later.");
}
}
}
private void navigate(Holder holder, int page) {
onPlayer(holder.session.player, () -> {
if (current(holder.session) && holder.session.view == holder
&& holder.session.player.getOpenInventory().getTopInventory() == holder.inventory)
renderSafely(holder.session, holder.snapshot, page);
}, () -> sessions.remove(holder.session.owner, holder.session));
}
private void render(Session session, VoteStreaksSnapshot data, int page) {
if (!current(session) || !permitted(session.player)) return;
Holder holder = new Holder(this, session, data, page);
String title = plugin.getGui().getChestVoteStreaksName();
holder.inventory = Bukkit.createInventory(holder, 54, MessageAPI.colorize(title));
for (int i = 0; i < data.periods().size(); i++) {
var period = data.periods().get(i); String unit = VoteStreaksSnapshot.unit(period.type());
holder.inventory.setItem(11 + i * 2, item(Material.CLOCK, "&6" + period.type() + " streak",
List.of("&7Current: &e" + period.current() + " " + unit, "&7Best: &e" + period.best() + " " + unit)));
}
List<VoteStreaksSnapshot.Milestone> milestones = data.page(holder.page);
for (int i = 0; i < milestones.size(); i++) {
var milestone = milestones.get(i); String unit = VoteStreaksSnapshot.unit(milestone.type());
var lore = new ArrayList<String>();
lore.add("&7Current: &e" + milestone.current() + " " + unit);
lore.add("&7Best: &e" + milestone.best() + " " + unit);
lore.add("&7Milestone: &e" + milestone.threshold() + " " + unit);
lore.add("&7Votes per period: &e" + milestone.votesRequired());
lore.add(milestone.recurring() ? "&7Repeats every " + milestone.threshold() + " " + unit + "; next: " + milestone.nextThreshold()
: milestone.current() >= milestone.threshold() ? "&aCurrent streak meets this milestone" : "&7Upcoming milestone");
lore.add("&7Progress only; this does not claim rewards.");
holder.inventory.setItem(20 + i, item(Material.PAPER, "&e" + milestone.id(), lore));
}
if (milestones.isEmpty()) holder.inventory.setItem(22, item(Material.PAPER, "&7No enabled streak milestones",
List.of("&7Configure VoteStreaks in SpecialRewards.yml.")));
holder.inventory.setItem(45, item(Material.ARROW, "&ePrevious", List.of()));
holder.inventory.setItem(49, item(Material.BARRIER, "&cClose", List.of()));
holder.inventory.setItem(50, item(Material.CLOCK, "&eRefresh", List.of("&7Read current streaks again.")));
holder.inventory.setItem(53, item(Material.ARROW, "&eNext", List.of("&7Page " + (holder.page + 1) + "/" + data.pages(),
data.truncated() ? "&7Showing the first 512 enabled milestones." : "&7Read-only streak progression.")));
session.view = holder;
if (!current(session) || !permitted(session.player)) return;
session.player.openInventory(holder.inventory);
if (!current(session) && session.player.getOpenInventory().getTopInventory() == holder.inventory)
session.player.closeInventory();
if (session.player.getOpenInventory().getTopInventory() != holder.inventory) sessions.remove(session.owner, session);
}
private static ItemStack item(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material); ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(MessageAPI.colorize(name));
meta.setLore(lore.stream().map(MessageAPI::colorize).toList()); item.setItemMeta(meta);
}
return item;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void inventoryOpen(InventoryOpenEvent event) {
if (!(event.getPlayer() instanceof Player player)) return;
Holder holder = holder(event.getInventory());
if (holder == null) return;
if (holder.session.player != player || !current(holder.session) || holder.session.view != holder || !permitted(player)) {
event.setCancelled(true); return;
}
if (event.isCancelled()) sessions.remove(holder.session.owner, holder.session);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void unrelatedInventoryOpen(InventoryOpenEvent event) {
if (!event.isCancelled() && holder(event.getInventory()) == null && event.getPlayer() instanceof Player player)
sessions.remove(player.getUniqueId());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void click(InventoryClickEvent event) {
Holder holder = holder(event.getView().getTopInventory());
if (holder == null) return;
boolean cancelled = event.isCancelled(); event.setCancelled(true);
if (cancelled || event.getWhoClicked() != holder.session.player || !current(holder.session)
|| holder.session.view != holder || !permitted(holder.session.player)) return;
// Only ordinary top-inventory clicks navigate; shift/number/drop actions cannot move previews.
if (event.getRawSlot() < 0 || event.getRawSlot() >= 54 || !event.isLeftClick() || event.isShiftClick()) return;
switch (event.getRawSlot()) {
case 45 -> navigate(holder, holder.page - 1);
case 53 -> navigate(holder, holder.page + 1);
case 49 -> { sessions.remove(holder.session.owner, holder.session); closeView(holder.session); }
case 50 -> begin(holder.session.player);
default -> { }
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void drag(InventoryDragEvent event) {
if (holder(event.getView().getTopInventory()) != null) event.setCancelled(true);
}
@EventHandler public void inventoryClose(InventoryCloseEvent event) {
Holder holder = holder(event.getInventory());
if (holder != null && holder.session.view == holder) sessions.remove(holder.session.owner, holder.session);
else if (event.getPlayer() instanceof Player player) {
Session pending = sessions.get(player.getUniqueId());
if (pending != null && pending.view == null && pending.previous == event.getInventory()) sessions.remove(pending.owner, pending);
}
}
@EventHandler public void quit(PlayerQuitEvent event) { sessions.remove(event.getPlayer().getUniqueId()); }
@EventHandler public void worldChange(PlayerChangedWorldEvent event) { end(event.getPlayer()); }
@EventHandler public void death(PlayerDeathEvent event) { end(event.getEntity()); }
private void end(Player player) { Session session = sessions.remove(player.getUniqueId()); if (session != null) closeView(session); }
private boolean retire(Session session) {
if (!sessions.remove(session.owner, session)) return false;
closeView(session); return true;
}
private void closeView(Session session) {
if (session.view == null && !session.ownsPrevious) return;
Runnable cleanup = () -> {
// A queued cleanup must never close an inventory adopted by a newer request.
Session replacement = sessions.get(session.owner);
if (replacement != null && replacement != session) return;
Inventory top = session.player.getOpenInventory().getTopInventory();
if ((session.view != null && top == session.view.inventory)
|| (session.ownsPrevious && top == session.previous)) session.player.closeInventory();
};
// Bukkit rejects scheduling after disable. Its primary thread already owns this interaction.
if (closed && ownsForShutdown(Bukkit.class, session.player)) cleanup.run();
else onPlayer(session.player, cleanup, () -> { });
}
/** Public Folia ownership also recognizes its stopped-region shutdown owner. */
static boolean ownsForShutdown(Class<?> platform, Player player) {
try {
return Boolean.TRUE.equals(platform.getMethod("isOwnedByCurrentRegion", org.bukkit.entity.Entity.class).invoke(null, player));
} catch (NoSuchMethodException legacy) {
try {
Class.forName("io.papermc.paper.threadedregions.RegionizedServer", false, VoteStreaksMenu.class.getClassLoader());
return false; // A regional server without its ownership API must fail closed.
} catch (ClassNotFoundException ordinaryServer) { return Bukkit.isPrimaryThread(); }
catch (LinkageError unavailable) { return false; }
} catch (ReflectiveOperationException | LinkageError unavailable) { return false; }
}
public void suspend() { reloadDepth.incrementAndGet(); reset(); }
public void resume() { reloadDepth.updateAndGet(value -> Math.max(0, value - 1)); }
public void reset() {
List<Session> old;
synchronized (sessions) { old = List.copyOf(sessions.values()); sessions.clear(); }
old.forEach(this::closeView);
}
@Override public void close() { synchronized (sessions) { closed = true; } reset(); }
int sessionCount() { return sessions.size(); }
}
@@ -0,0 +1,37 @@
package com.bencodez.votingplugin.streaks;
import java.util.List;
import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType;
/** Read-only observations of existing state; never evidence that a reward was delivered. */
public record VoteStreaksSnapshot(List<Period> periods, List<Milestone> milestones, boolean truncated) {
public static final int MAX_MILESTONES = 512;
public static final int PAGE_SIZE = 5;
public VoteStreaksSnapshot {
periods = List.copyOf(periods);
milestones = List.copyOf(milestones);
if (periods.size() != 3 || milestones.size() > MAX_MILESTONES) throw new IllegalArgumentException("Invalid streak snapshot size");
}
public record Period(VoteStreakType type, int current, int best) {
public Period { current = Math.max(0, current); best = Math.max(current, best); }
}
public record Milestone(String id, VoteStreakType type, int current, int best,
int threshold, int votesRequired, boolean recurring) {
public Milestone {
current = Math.max(0, current); best = Math.max(current, best);
threshold = Math.max(1, threshold); votesRequired = Math.max(1, votesRequired);
}
public long nextThreshold() {
return recurring ? ((long) current / threshold + 1L) * threshold : threshold;
}
}
public int pages() { return Math.max(1, (milestones.size() + PAGE_SIZE - 1) / PAGE_SIZE); }
public int clampPage(int page) { return Math.max(0, Math.min(page, pages() - 1)); }
public List<Milestone> page(int page) {
int start = clampPage(page) * PAGE_SIZE;
return milestones.subList(start, Math.min(start + PAGE_SIZE, milestones.size()));
}
public static String unit(VoteStreakType type) {
return switch (type) { case DAILY -> "days"; case WEEKLY -> "weeks"; case MONTHLY -> "months"; };
}
}
+9 -6
View File
@@ -188,6 +188,7 @@ DebugLevel: NONE
GiveDefaultPermission: true
# If true, plugin will automatically generate votesites upon getting a vote
# Disable after setup; a small periodic console reminder warns while enabled.
AutoCreateVoteSites: true
# Geyser/Bedrock player support
@@ -211,6 +212,14 @@ UseVoteGUIMainCommand: false
# Does not backup MySQL data
CreateBackups: true
# Player root command and standalone player aliases; admin commands remain available.
# These settings require a server restart; /av reload does not change registration.
VoteCommandEnabled: true
# Root label without a slash or namespace; 1-64 letters, digits, underscores or hyphens.
# Reserved/invalid names use vote. A foreign bare-name conflict uses votingplugin:<name>.
# Standalone aliases such as /votegui and /vgui keep their existing names.
VoteCommandName: vote
# Load command aliases, such as /avgui
# Requires restart to take effect
LoadCommandAliases: true
@@ -1256,9 +1265,3 @@ DisableUpdateChecking: false
# Disable checking permissions on tab complete
# Could increase performance slightly, but probably not
DisableAdvancedTab: false
# Optional public vanilla villagers opening the existing voting-site menu.
# Enable and /av reload, then /av npc create <id> [name]. No Citizens required.
# Definitions live in VotingNPCs.json; disabling keeps them but removes live NPCs.
VotingNPCs:
Enabled: false
+4
View File
@@ -294,6 +294,10 @@ CHEST:
Lore: '&c&lBest: &c%Best%'
Amount: 1
# Separate read-only /vote streaks progression menu.
VoteStreaks:
Name: '&6Vote streaks'
VoteStreak:
Name: 'VoteStreak: %player%'
BackButton: false
@@ -6,7 +6,7 @@ license="CC BY 3.0"
modId="votingplugin"
version="${project.version}"
displayName="VotingPlugin"
description="VotingPlugin NeoForge bootstrap (vote processing is not available yet)."
description="Experimental VotingPlugin NeoForge support for authenticated proxy SOCKETS votes and limited reward replay."
[[dependencies.votingplugin]]
modId="neoforge"
+3 -5
View File
@@ -170,8 +170,6 @@ permissions:
description: Allows mod commands
VotingPlugin.Admin:
description: Allows admincommands
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.TopVoter.Ignore:
description: Allows you to be exempt from top voter
VotingPlugin.NoRemind:
@@ -198,8 +196,8 @@ permissions:
description: Remind if player can vote on all votesites, rather than any
default: false
VotingPlugin.Commands.AdminVote.NPC:
description: Create, inspect, list and remove saved vanilla voting NPCs
VotingPlugin.Commands.Vote.Streaks:
description: View your read-only vote streak progression
default: op
children:
VotingPlugin.Commands.AdminVote: true
VotingPlugin.Commands.Vote: true
@@ -1,203 +0,0 @@
package com.bencodez.votingplugin.npc;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
import com.bencodez.votingplugin.VotingPluginMain;
class VotingNPCCommandsTest {
private static class Fixture {
final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
final BukkitScheduler scheduler = mock(BukkitScheduler.class);
final VotingNPCManager manager = mock(VotingNPCManager.class);
final ArrayList<CommandHandler> handlers = new ArrayList<>();
final LinkedBlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
final LinkedBlockingQueue<Runnable> asynchronous = new LinkedBlockingQueue<>();
Fixture() {
when(plugin.getAdminVoteCommand()).thenReturn(handlers);
when(plugin.getVotingNPCManager()).thenReturn(manager);
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
when(manager.available()).thenReturn(true);
doAnswer(inv -> { tasks.add(inv.getArgument(1)); return null; })
.when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
doAnswer(inv -> { tasks.add(inv.getArgument(1)); return null; })
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
doAnswer(inv -> { asynchronous.add(inv.getArgument(1)); return null; })
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
VotingNPCCommands.register(plugin);
}
Player player() {
Player player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(true);
World world = mock(World.class); when(world.getUID()).thenReturn(new UUID(1, 0));
// Location holds its world weakly; retain it as a real online player does.
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 4, 64, 4));
return player;
}
CommandHandler command(String action, int arguments) {
return handlers.stream().filter(value -> value.getArgs()[1].equals(action)
&& value.getArgs().length == arguments).findFirst().orElseThrow();
}
}
@Test void usesOnlyDedicatedNpcPatternsAndExplicitAdminPermission() {
var f = new Fixture();
assertEquals(List.of(List.of("NPC", "Create", "(string)"), List.of("NPC", "Create", "(string)", "(list)"),
List.of("NPC", "List"), List.of("NPC", "Inspect", "(npcid)"), List.of("NPC", "Remove", "(npcid)")),
f.handlers.stream().map(value -> List.of(value.getArgs())).toList());
for (var handler : f.handlers) assertEquals("VotingPlugin.Commands.AdminVote.NPC|VotingPlugin.Admin", handler.getPerm());
}
@Test void metadataGrantsTheAdminRootGateWithoutGrantingOtherAdminLeaves() throws Exception {
org.bukkit.plugin.PluginDescriptionFile description;
try (var input = getClass().getResourceAsStream("/plugin.yml")) {
assertNotNull(input); description = new org.bukkit.plugin.PluginDescriptionFile(input);
}
var permissions = description.getPermissions().stream().collect(java.util.stream.Collectors.toMap(
permission -> permission.getName().toLowerCase(java.util.Locale.ROOT), java.util.function.Function.identity()));
var plugin = mock(VotingPluginMain.class); when(plugin.isEnabled()).thenReturn(true);
var pm = mock(org.bukkit.plugin.PluginManager.class);
when(pm.getDefaultPermissions(false)).thenReturn(java.util.Set.of());
when(pm.getPermission(anyString())).thenAnswer(call -> permissions.get(((String)call.getArgument(0)).toLowerCase(java.util.Locale.ROOT)));
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(pm);
var server = mock(org.bukkit.Server.class); when(server.getPluginManager()).thenReturn(pm);
bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server);
for (String grant : List.of("VotingPlugin.Commands.AdminVote.NPC", "VotingPlugin.Admin")) {
var permissible = new org.bukkit.permissions.PermissibleBase(mock(org.bukkit.permissions.ServerOperator.class));
permissible.addAttachment(plugin, grant, true);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission(anyString())).thenAnswer(call -> permissible.hasPermission((String)call.getArgument(0)));
var root = new org.bukkit.command.Command("av") {
@Override public boolean execute(CommandSender sender, String label, String[] args) { return true; }
};
root.setPermission("VotingPlugin.Commands.AdminVote");
assertTrue(root.testPermissionSilent(sender), grant);
if (grant.endsWith(".NPC")) {
assertFalse(permissible.hasPermission("VotingPlugin.Commands.AdminVote.Edit.Config"));
assertFalse(permissible.hasPermission("VotingPlugin.Admin"));
}
}
}
}
@Test void capturesPlayerStateOnlyOnOwnerAndSuccessOnlyAfterDurableCompletion() {
var f = new Fixture(); Player player = f.player();
var completion = new CompletableFuture<VotingNPCDefinition>();
when(f.manager.create(any())).thenReturn(completion);
f.command("Create", 4).execute(player, new String[] { "NPC", "Create", "voting", "Vote", "here" });
verify(player, never()).getLocation(); verify(f.manager, never()).create(any());
f.tasks.remove().run();
var definition = ArgumentCaptor.forClass(VotingNPCDefinition.class); verify(f.manager).create(definition.capture());
assertEquals("Vote here", definition.getValue().name()); assertEquals("voting", definition.getValue().id());
verify(player, never()).sendMessage(anyString());
completion.complete(definition.getValue());
verify(player, never()).sendMessage(anyString());
f.tasks.remove().run();
verify(player).sendMessage(contains("Saved voting NPC 'voting'"));
}
@Test void delayedPermissionRevocationAndDisabledFeaturePreventMutation() {
var f = new Fixture(); Player player = f.player();
var handler = f.command("Create", 3);
handler.execute(player, new String[] { "NPC", "Create", "voting" });
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(false);
f.tasks.remove().run(); verify(f.manager, never()).create(any());
when(player.hasPermission("VotingPlugin.Commands.AdminVote.NPC")).thenReturn(true);
when(f.manager.available()).thenReturn(false);
handler.execute(player, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
verify(f.manager, never()).create(any()); verify(player).sendMessage(contains("VotingNPCs.Enabled"));
}
@Test void rejectedStorageDoesNotSendSuccessAndDoesNotLeakRawFailure() {
var f = new Fixture(); Player player = f.player();
when(f.manager.remove("voting")).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("Synthetic private diagnostic")));
f.command("Remove", 3).execute(player, new String[] { "NPC", "Remove", "voting" });
f.tasks.remove().run(); f.tasks.remove().run();
verify(player).sendMessage(contains("was not confirmed"));
verify(player, never()).sendMessage(contains("Synthetic private"));
verify(player, never()).sendMessage(startsWith("Removed saved"));
}
@Test void validationRejectionsHaveFixedActionableMessagesAndUnavailableStorageStaysConservative() {
for (var reason : VotingNPCRegistry.Rejection.values()) {
var f = new Fixture(); Player player = f.player();
when(f.manager.create(any())).thenReturn(CompletableFuture.failedFuture(
new java.util.concurrent.CompletionException(new VotingNPCRegistry.ValidationFailure(reason))));
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" });
f.tasks.remove().run(); f.tasks.remove().run();
String expected = switch (reason) {
case DUPLICATE_ID -> "ID already exists";
case DUPLICATE_IDENTITY -> "identity already exists";
case LIMIT -> "64 voting NPC limit";
};
verify(player).sendMessage(contains(expected));
verify(player, never()).sendMessage(contains("reload before retrying"));
}
var f = new Fixture(); Player player = f.player();
var pending = new CompletableFuture<VotingNPCDefinition>();
when(f.manager.create(any())).thenReturn(pending);
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
when(f.manager.available()).thenReturn(false);
pending.completeExceptionally(new java.util.concurrent.RejectedExecutionException("Synthetic")); f.tasks.remove().run();
verify(player).sendMessage(contains("reload before retrying"));
verify(player, never()).sendMessage(contains("busy"));
}
@Test void healthyQueueSaturationDoesNotTellTheAdministratorToReloadStorage() {
var f = new Fixture(); Player player = f.player();
when(f.manager.create(any())).thenReturn(CompletableFuture.failedFuture(
new java.util.concurrent.RejectedExecutionException("Synthetic queue rejection")));
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "voting" });
f.tasks.remove().run(); f.tasks.remove().run();
verify(player).sendMessage(contains("busy"));
verify(player, never()).sendMessage(contains("reload before retrying"));
verify(player, never()).sendMessage(contains("Synthetic"));
}
@Test void invalidDefinitionAndConsoleCreationDoNotMutate() {
var f = new Fixture(); Player player = f.player();
f.command("Create", 3).execute(player, new String[] { "NPC", "Create", "bad id" }); f.tasks.remove().run();
verify(f.manager, never()).create(any()); verify(player).sendMessage(contains("lowercase ID"));
CommandSender console = mock(CommandSender.class); when(console.hasPermission("VotingPlugin.Admin")).thenReturn(true);
f.command("Create", 3).execute(console, new String[] { "NPC", "Create", "voting" }); f.tasks.remove().run();
verify(console).sendMessage("Create a voting NPC from in game."); verify(f.manager, never()).create(any());
}
@Test void bothCreationFormsMatchRealCommandDispatchWithStandardFrameworkPlaceholders() {
var tabs = new com.bencodez.simpleapi.command.TabCompleteHandler();
// These are the standard placeholders AdvancedCore registers before commands.
tabs.addTabCompleteOption("(String)", new ArrayList<>());
tabs.addTabCompleteOption("(List)", new ArrayList<>());
try (var tabSingleton = mockStatic(com.bencodez.simpleapi.command.TabCompleteHandler.class)) {
tabSingleton.when(com.bencodez.simpleapi.command.TabCompleteHandler::getInstance).thenReturn(tabs);
var f = new Fixture(); Player player = f.player();
when(f.manager.create(any())).thenReturn(new CompletableFuture<>());
assertTrue(f.command("Create", 3).runCommand(player, new String[] { "npc", "create", "new-voting" }));
f.asynchronous.remove().run(); f.tasks.remove().run();
assertTrue(f.command("Create", 4).runCommand(player, new String[] { "npc", "create", "another", "Vote", "here" }));
f.asynchronous.remove().run(); f.tasks.remove().run();
var values = ArgumentCaptor.forClass(VotingNPCDefinition.class); verify(f.manager, times(2)).create(values.capture());
assertEquals(List.of("new-voting", "another"), values.getAllValues().stream().map(VotingNPCDefinition::id).toList());
}
}
}
@@ -1,556 +0,0 @@
package com.bencodez.votingplugin.npc;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.AdditionalMatchers.aryEq;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
import com.bencodez.votingplugin.VotingPluginMain;
class VotingNPCManagerTest {
@TempDir Path directory;
private static final UUID WORLD = new UUID(1, 0);
private static VotingNPCDefinition definition(int identity) {
return new VotingNPCDefinition("voting", new UUID(0, identity), WORLD, 8, 64, 8, 0, 0, "&aVote");
}
private static VotingNPCDefinition definitionAt(int identity, double x) {
return new VotingNPCDefinition("npc-" + identity, new UUID(0, identity), WORLD, x, 64, 8, 0, 0, "&aVote");
}
private static <T> T await(CompletionStage<T> stage) throws Exception {
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static class Scheduler extends VotingNPCScheduler {
final LinkedBlockingQueue<Runnable> global = new LinkedBlockingQueue<>();
final LinkedBlockingQueue<Runnable> regions = new LinkedBlockingQueue<>();
final LinkedBlockingQueue<Runnable> entities = new LinkedBlockingQueue<>();
String owner = "none";
boolean rejectCleanup;
Scheduler(VotingPluginMain plugin) { super(plugin); }
@Override boolean owns(Location value) { return owner.equals("region"); }
@Override boolean owns(Entity value) { return owner.equals("entity"); }
@Override void global(Runnable action, Consumer<Throwable> failed) { global.add(action); }
@Override void region(Location anchor, Runnable action, Consumer<Throwable> failed) { regions.add(action); }
@Override void entity(Entity entity, Runnable action, Runnable retired, Consumer<Throwable> failed) {
if (rejectCleanup) failed.accept(new IllegalStateException("Synthetic rejection"));
else entities.add(action);
}
void runNext(LinkedBlockingQueue<Runnable> queue, String phase) throws Exception {
Runnable action = queue.poll(5, TimeUnit.SECONDS);
assertNotNull(action, "Expected scheduled " + phase + " work");
owner = phase;
try { action.run(); } finally { owner = "none"; }
}
}
@Test void duplicateChunkNotificationsDoNotReconstructAnotherEntityAndOrdinaryEntitiesAreIgnored() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin);
List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
var manager = new VotingNPCManager(plugin, scheduler, registry, player -> fail("Unexpected menu"));
try {
manager.configure(true);
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
Chunk chunk = chunk(world);
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
verify(spawned.getFirst()).setPersistent(false);
verify(spawned.getFirst()).setAI(false);
verify(spawned.getFirst()).setCustomName("\u00a7aVote");
Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(new UUID(0, 100));
Villager unrelated = mock(Villager.class); when(unrelated.getUniqueId()).thenReturn(new UUID(0, 99));
var interact = new PlayerInteractEntityEvent(player, unrelated, EquipmentSlot.HAND);
manager.interact(interact); assertFalse(interact.isCancelled());
var damage = mock(EntityDamageEvent.class); when(damage.getEntity()).thenReturn(unrelated);
manager.damage(damage); verify(damage, never()).setCancelled(anyBoolean());
var protectedDamage = mock(EntityDamageEvent.class); when(protectedDamage.getEntity()).thenReturn(spawned.getFirst());
manager.damage(protectedDamage); verify(protectedDamage).setCancelled(true);
var hostileTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, spawned.getFirst(),
org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_ENTITY);
manager.target(hostileTarget);
assertTrue(hostileTarget.isCancelled(), "ordinary mobs must not target a tracked NPC");
var normalTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, player,
org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_ENTITY);
manager.target(normalTarget); assertFalse(normalTarget.isCancelled());
var clearedTarget = new org.bukkit.event.entity.EntityTargetEvent(unrelated, null,
org.bukkit.event.entity.EntityTargetEvent.TargetReason.FORGOT_TARGET);
manager.target(clearedTarget); assertFalse(clearedTarget.isCancelled());
var witch = mock(org.bukkit.entity.Witch.class);
var transform = new org.bukkit.event.entity.EntityTransformEvent(spawned.getFirst(), List.of(witch),
org.bukkit.event.entity.EntityTransformEvent.TransformReason.LIGHTNING);
var unrelatedTransform = new org.bukkit.event.entity.EntityTransformEvent(unrelated, List.of(witch),
org.bukkit.event.entity.EntityTransformEvent.TransformReason.LIGHTNING);
// Discover actual listener methods, as event registration does;
// absence of a transformation handler must fail this regression.
for (var method : VotingNPCManager.class.getDeclaredMethods()) {
if (method.isAnnotationPresent(org.bukkit.event.EventHandler.class) && method.getParameterCount() == 1
&& method.getParameterTypes()[0].isInstance(transform)) {
method.invoke(manager, transform); method.invoke(manager, unrelatedTransform);
}
}
assertTrue(transform.isCancelled(), "lightning must not replace a tracked villager with an untracked entity");
assertFalse(unrelatedTransform.isCancelled());
} finally {
manager.close(); scheduler.runNext(scheduler.entities, "entity");
assertEquals(0, manager.runtimeCount()); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
@Test void freedFullCapacityWakesOnePendingDifferentId() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("capacity.json")); await(registry.initialLoad());
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global");
for (int i = 0; i < VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
scheduler.runNext(scheduler.entities, "entity");
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
assertEquals(VotingNPCStore.MAX_NPCS + 1, spawned.size());
} finally {
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
@Test void unloadedCurrentReservationWakesDifferentPendingIdWithoutRetryingItself() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("unloaded-current-capacity.json")); await(registry.initialLoad());
for (int i = 1; i < VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global");
for (int i = 1; i < VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
await(manager.create(definitionAt(64, 128)));
scheduler.runNext(scheduler.global, "global");
assertEquals(64, manager.runtimeCount()); assertEquals(1, scheduler.regions.size());
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
assertEquals(1, scheduler.entities.size()); assertTrue(scheduler.global.isEmpty());
assertEquals(1, scheduler.regions.size());
// The existing queued reservation is now unloaded; the new target remains loaded.
when(world.isChunkLoaded(anyInt(), anyInt())).thenAnswer(call -> (Integer) call.getArgument(0) != 8);
scheduler.runNext(scheduler.regions, "region");
assertEquals(63, manager.runtimeCount());
assertEquals(1, scheduler.global.size(), "Released capacity must wake the different waiting ID");
scheduler.runNext(scheduler.global, "global");
assertEquals(1, scheduler.regions.size(), "Only the pending ID may retry, not the unloaded reservation");
scheduler.runNext(scheduler.regions, "region");
assertEquals(64, spawned.size()); assertEquals(64, manager.runtimeCount());
assertTrue(scheduler.global.isEmpty()); assertTrue(scheduler.regions.isEmpty());
verify(world).spawn(argThat(anchor -> anchor.getX() == 2000), eq(Villager.class), any(Consumer.class));
} finally {
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
@Test void successorWaitsForExactPredecessorCleanupAndCleanupRejectionRetainsOwnership() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
scheduler.rejectCleanup = true;
assertTrue(await(manager.remove("voting")));
await(manager.create(definition(2)));
// Process both durable mutation notifications while old removal is rejected.
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
verify(spawned.getFirst(), never()).remove();
// A rejected cleanup keeps ownership; repeated load notifications may
// request a retry, but must never create a successor or duplicate task.
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
assertEquals(1, spawned.size()); assertEquals(1, manager.runtimeCount());
scheduler.rejectCleanup = false;
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false));
scheduler.runNext(scheduler.entities, "entity");
verify(spawned.getFirst()).remove();
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
} finally {
scheduler.rejectCleanup = false; manager.close();
if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
@Test void transientUnloadCleanupRetriesAtLoadAndCoalescesUntilRemovalFinishes() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
Chunk chunk = chunk(world); scheduler.rejectCleanup = true;
manager.chunkUnload(new ChunkUnloadEvent(chunk));
assertEquals(1, manager.runtimeCount()); assertTrue(scheduler.global.isEmpty());
assertTrue(scheduler.entities.isEmpty(), "rejection must not create an autonomous retry loop");
scheduler.rejectCleanup = false;
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
manager.chunkLoad(new ChunkLoadEvent(chunk, false));
assertEquals(1, scheduler.entities.size(), "a queued exact predecessor cleanup must coalesce");
assertEquals(1, spawned.size());
scheduler.runNext(scheduler.entities, "entity"); verify(spawned.getFirst()).remove();
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
assertTrue(scheduler.entities.isEmpty()); assertTrue(scheduler.global.isEmpty());
} finally {
scheduler.rejectCleanup = false; manager.close();
if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
@Test void queuedRemovedReservationWakesPendingCapacityWithoutEntity() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("queued-capacity.json")); await(registry.initialLoad());
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global");
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
for (int i = 0; i <= VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
assertTrue(scheduler.regions.isEmpty()); assertTrue(scheduler.global.isEmpty());
assertEquals(VotingNPCStore.MAX_NPCS, manager.runtimeCount());
assertEquals(VotingNPCStore.MAX_NPCS, spawned.size());
} finally {
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
@Test void unloadedQueuedReservationWithPendingCapacityDoesNotRetry() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("unloaded-capacity.json")); await(registry.initialLoad());
for (int i = 1; i <= VotingNPCStore.MAX_NPCS; i++) await(registry.create(definitionAt(i, i * 2)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global");
await(manager.remove("npc-1")); await(manager.create(definitionAt(1000, 2000)));
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.global, "global");
for (int i = 0; i <= VotingNPCStore.MAX_NPCS; i++) scheduler.runNext(scheduler.regions, "region");
assertEquals(0, spawned.size()); assertTrue(scheduler.global.isEmpty());
assertTrue(scheduler.regions.isEmpty()); assertEquals(0, manager.runtimeCount());
} finally { manager.close(); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
}
}
@Test void unloadedChunksNeverSpawnOrLoadAndLaterLoadReconstructsOnce() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
assertTrue(spawned.isEmpty()); assertEquals(0, manager.runtimeCount());
verify(world, never()).getChunkAt(anyInt(), anyInt()); verify(world, never()).loadChunk(anyInt(), anyInt());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false)); scheduler.runNext(scheduler.regions, "region");
assertEquals(1, spawned.size());
manager.chunkUnload(new ChunkUnloadEvent(chunk(world))); scheduler.runNext(scheduler.entities, "entity");
assertTrue(scheduler.global.isEmpty(), "unload must not immediately retry reconstruction");
assertTrue(scheduler.regions.isEmpty());
manager.chunkLoad(new ChunkLoadEvent(chunk(world), false)); scheduler.runNext(scheduler.regions, "region");
assertEquals(2, spawned.size());
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
}
}
@Test void partialSpawnFailureRemovesOnlyTheCapturedEntityAndCloseInvalidatesQueuedSpawns() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global");
// Close before a deferred creation runs: no entity may be created.
manager.configure(false); scheduler.runNext(scheduler.regions, "region");
assertTrue(spawned.isEmpty()); assertEquals(0, manager.runtimeCount());
manager.configure(true); scheduler.runNext(scheduler.global, "global");
doAnswer(invocation -> {
Villager entity = mock(Villager.class); when(entity.getUniqueId()).thenReturn(new UUID(0, 17));
doThrow(new IllegalStateException("Synthetic entity property failure")).when(entity).setAI(false);
spawned.add(entity);
Consumer<Villager> initializer = invocation.getArgument(2); initializer.accept(entity); return entity;
}).when(world).spawn(any(Location.class), eq(Villager.class), any(Consumer.class));
scheduler.runNext(scheduler.regions, "region");
assertEquals(1, manager.runtimeCount());
scheduler.runNext(scheduler.entities, "entity");
verify(spawned.getFirst()).remove(); assertEquals(0, manager.runtimeCount());
assertTrue(scheduler.global.isEmpty(), "failed spawn must not cause an automatic retry loop");
} finally { manager.close(); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
}
}
@Test void occupiedNeighbourUnloadRetiresAndReloadReconstructsOnceAtBothCoordinateSigns() throws Exception {
for (double position : new double[] {15.75, -.25}) {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("boundary-" + (position > 0) + ".json"));
await(registry.initialLoad());
var definition = new VotingNPCDefinition("voting", new UUID(0, 1), WORLD, position, 64, position, 0, 0, "Vote");
await(registry.create(definition));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, 7, 7)));
assertTrue(scheduler.entities.isEmpty(), "unrelated chunk unload must leave NPC alone");
int neighbour = definition.chunkX() + 1;
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, neighbour, neighbour)));
assertEquals(1, scheduler.entities.size(), "occupied adjacent chunk must trigger entity-owned cleanup");
// Bukkit may still report the unloading chunk as loaded during its event.
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, definition.chunkX(), definition.chunkZ()), false));
scheduler.runNext(scheduler.entities, "entity");
assertEquals(0, manager.runtimeCount()); assertTrue(scheduler.regions.isEmpty());
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, neighbour, neighbour), false));
manager.chunkLoad(new ChunkLoadEvent(atChunk(world, neighbour, neighbour), false));
scheduler.runNext(scheduler.regions, "region");
assertEquals(2, spawned.size()); assertEquals(1, manager.runtimeCount());
verify(world, never()).getChunkAt(anyInt(), anyInt()); verify(world, never()).loadChunk(anyInt(), anyInt());
} finally {
manager.close(); if (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
}
@Test void neighbourUnloadFenceSurvivesReconciliationAndBlocksDeferredCreation() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("pending-boundary.json")); await(registry.initialLoad());
await(registry.create(new VotingNPCDefinition("voting", new UUID(0, 1), WORLD, 15.75, 64, 15.75, 0, 0, "Vote")));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global");
manager.chunkUnload(new ChunkUnloadEvent(atChunk(world, 1, 1)));
scheduler.runNext(scheduler.regions, "region");
assertTrue(spawned.isEmpty(), "a deferred creation cannot cross an occupied chunk unload");
assertEquals(0, manager.runtimeCount());
await(manager.create(new VotingNPCDefinition("other", new UUID(0, 2), WORLD, 40, 64, 40, 0, 0, "Vote")));
scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
assertEquals(1, spawned.size(), "unrelated mutation must not erase the first NPC's occupied-chunk fence");
assertTrue(scheduler.regions.isEmpty());
} finally {
manager.close(); while (!scheduler.entities.isEmpty()) scheduler.runNext(scheduler.entities, "entity");
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
}
}
private static Chunk atChunk(World world, int x, int z) {
Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world);
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); return chunk;
}
@Test void disabledFeatureDoesNotReadStorageOrRegisterAnyRuntimeWork() {
var plugin = mock(VotingPluginMain.class);
var scheduler = new Scheduler(plugin);
var manager = new VotingNPCManager(plugin, scheduler, null, ignored -> fail("Disabled menu"));
manager.configure(false);
assertFalse(manager.available()); assertEquals(0, manager.runtimeCount());
assertTrue(scheduler.global.isEmpty()); assertTrue(scheduler.regions.isEmpty());
verify(plugin, never()).getDataFolder();
manager.close(); manager.close();
}
@Test void worldUnloadPreservesDefinitionsAndAWorldLoadAfterCleanupRecreatesOnce() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
var manager = new VotingNPCManager(plugin, scheduler, registry, ignored -> { });
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
manager.worldUnload(new WorldUnloadEvent(world));
// A load before delayed cleanup must record demand, not spawn a duplicate.
manager.worldLoad(new WorldLoadEvent(world)); scheduler.runNext(scheduler.global, "global");
assertEquals(1, spawned.size());
scheduler.runNext(scheduler.entities, "entity"); scheduler.runNext(scheduler.global, "global");
scheduler.runNext(scheduler.regions, "region");
assertEquals(2, spawned.size()); assertEquals(List.of(definition(1)), manager.definitions());
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
}
}
@Test void cancelledAndOffhandClicksDoNotOpenAndRemovalInvalidatesAQueuedPlayerCallback() throws Exception {
var plugin = mock(VotingPluginMain.class); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
var bukkitScheduler = mock(BukkitScheduler.class); when(plugin.getBukkitScheduler()).thenReturn(bukkitScheduler);
var playerCallbacks = new LinkedBlockingQueue<Runnable>();
doAnswer(invocation -> { playerCallbacks.add(invocation.getArgument(1)); return null; })
.when(bukkitScheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class));
try (var bukkit = mockStatic(Bukkit.class)) {
World world = mock(World.class); when(world.getUID()).thenReturn(WORLD);
bukkit.when(() -> Bukkit.getWorld(WORLD)).thenReturn(world);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var scheduler = new Scheduler(plugin); List<Villager> spawned = installSpawner(world, scheduler);
var registry = new VotingNPCRegistry(directory.resolve("npcs.json")); await(registry.initialLoad());
await(registry.create(definition(1)));
List<Player> opened = new ArrayList<>();
var manager = new VotingNPCManager(plugin, scheduler, registry, opened::add);
try {
manager.configure(true); scheduler.runNext(scheduler.global, "global"); scheduler.runNext(scheduler.regions, "region");
Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(new UUID(0, 50));
when(player.isOnline()).thenReturn(true); when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 8, 64, 9));
var cancelled = new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.HAND); cancelled.setCancelled(true);
manager.interact(cancelled);
manager.interact(new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.OFF_HAND));
assertTrue(playerCallbacks.isEmpty());
var click = new PlayerInteractEntityEvent(player, spawned.getFirst(), EquipmentSlot.HAND);
manager.interact(click); assertTrue(click.isCancelled());
assertEquals(1, playerCallbacks.size());
playerCallbacks.remove().run();
assertEquals(List.of(player), opened, "healthy main-hand selection opens on the player callback");
Player second = mock(Player.class); when(second.getUniqueId()).thenReturn(new UUID(0, 51));
when(second.isOnline()).thenReturn(true); when(second.getWorld()).thenReturn(world);
when(second.getLocation()).thenReturn(new Location(world, 8, 64, 9));
manager.interact(new PlayerInteractEntityEvent(second, spawned.getFirst(), EquipmentSlot.HAND));
playerCallbacks.remove().run();
assertEquals(List.of(player, second), opened, "one player's selection must not block another player");
Player third = mock(Player.class); when(third.getUniqueId()).thenReturn(new UUID(0, 52));
when(third.isOnline()).thenReturn(true); when(third.getWorld()).thenReturn(world);
when(third.getLocation()).thenReturn(new Location(world, 8, 64, 9));
manager.interact(new PlayerInteractEntityEvent(third, spawned.getFirst(), EquipmentSlot.HAND));
await(manager.remove("voting"));
playerCallbacks.remove().run(); assertEquals(List.of(player, second), opened);
} finally { manager.close(); scheduler.runNext(scheduler.entities, "entity"); assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS)); }
}
}
@Test void productionMenuEntryDelegatesToTheExistingUrlCommandRatherThanAnotherMenuOrRewardPath() {
var plugin = mock(VotingPluginMain.class);
var unrelated = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
var url = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
var later = mock(com.bencodez.advancedcore.api.command.CommandHandler.class);
var player = mock(Player.class);
when(plugin.getVoteCommand()).thenReturn(new ArrayList<>(List.of(unrelated, url, later)));
when(url.runCommand(eq(player), aryEq(new String[] { "URL" }))).thenReturn(true);
VotingNPCManager.openVotingSites(plugin, player);
verify(unrelated).runCommand(eq(player), aryEq(new String[] { "URL" }));
verify(url).runCommand(eq(player), aryEq(new String[] { "URL" }));
verifyNoInteractions(later);
verify(player, never()).performCommand(anyString());
}
private static Chunk chunk(World world) {
Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world); when(chunk.getX()).thenReturn(0); when(chunk.getZ()).thenReturn(0); return chunk;
}
@SuppressWarnings("unchecked")
private static List<Villager> installSpawner(World world, Scheduler scheduler) {
List<Villager> spawned = new ArrayList<>();
doAnswer(invocation -> {
assertEquals("region", scheduler.owner);
Villager entity = mock(Villager.class); when(entity.getUniqueId()).thenReturn(new UUID(2, spawned.size() + 1));
when(entity.isValid()).thenReturn(true);
Consumer<Villager> initializer = invocation.getArgument(2); initializer.accept(entity);
doAnswer(removal -> { assertEquals("entity", scheduler.owner); return null; }).when(entity).remove();
spawned.add(entity); return entity;
}).when(world).spawn(any(Location.class), eq(Villager.class), any(Consumer.class));
return spawned;
}
}
@@ -1,237 +0,0 @@
package com.bencodez.votingplugin.npc;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class VotingNPCRegistryTest {
@TempDir Path directory;
private VotingNPCDefinition definition(int id) {
return new VotingNPCDefinition("npc-" + id, new UUID(0, id), new UUID(1, 0),
0.5, 64, 0.5, 0, 0, "Vote");
}
private static <T> T await(CompletionStage<T> stage) throws Exception {
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static void close(VotingNPCRegistry registry) throws InterruptedException {
registry.close();
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
}
private static void waitOn(CountDownLatch latch) throws IOException {
try {
if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("Synthetic worker latch timed out");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IOException("Synthetic worker interrupted", interrupted);
}
}
@Test void linkedDirectoryAliasesShareTheSameExclusiveOwnerAndLeafLockLinksAreRejected() throws Exception {
Path real = Files.createDirectory(directory.resolve("real"));
Path linked = Files.createSymbolicLink(directory.resolve("linked"), real);
Path file = linked.resolve("npcs.json");
var first = new VotingNPCRegistry(file);
try {
await(first.initialLoad()); await(first.create(definition(1)));
var alias = new VotingNPCRegistry(real.resolve("npcs.json"));
try { assertThrows(ExecutionException.class, () -> await(alias.initialLoad())); }
finally { close(alias); }
} finally { close(first); }
var recovered = new VotingNPCRegistry(file);
try { assertEquals(List.of(definition(1)), await(recovered.initialLoad())); }
finally { close(recovered); }
Path lock = linked.resolve("other.json.lock");
Path unrelated = real.resolve("unrelated"); Files.writeString(unrelated, "unchanged");
Files.createSymbolicLink(lock, unrelated);
var rejected = new VotingNPCRegistry(linked.resolve("other.json"));
try { assertThrows(ExecutionException.class, () -> await(rejected.initialLoad())); }
finally { close(rejected); }
assertEquals("unchanged", Files.readString(unrelated));
}
@Test void orderedDefinitionsAreDurableAndDuplicateRequestsDoNotOverwrite() throws Exception {
Path file = directory.resolve("npcs.json");
var registry = new VotingNPCRegistry(file);
try {
assertEquals(List.of(), await(registry.initialLoad()));
var first = registry.create(definition(1));
var duplicate = registry.create(definition(1));
var second = registry.create(definition(2));
assertEquals(definition(1), await(first));
assertInstanceOf(IllegalArgumentException.class,
assertThrows(ExecutionException.class, () -> await(duplicate)).getCause());
await(second);
assertEquals(List.of(definition(1), definition(2)), new VotingNPCStore(file).load());
assertTrue(await(registry.remove("npc-1")));
assertFalse(await(registry.remove("npc-1")));
} finally { close(registry); }
var restarted = new VotingNPCRegistry(file);
try { assertEquals(List.of(definition(2)), await(restarted.initialLoad())); }
finally { close(restarted); }
}
@Test void failedReloadRetainsLastSnapshotButCannotPublishUntilRepaired() throws Exception {
Path file = directory.resolve("npcs.json");
var registry = new VotingNPCRegistry(file);
try {
await(registry.initialLoad()); await(registry.create(definition(1)));
byte[] healthy = Files.readAllBytes(file);
Files.writeString(file, "{invalid");
assertThrows(ExecutionException.class, () -> await(registry.reload()));
assertFalse(registry.available());
assertEquals(List.of(definition(1)), registry.snapshot());
assertThrows(ExecutionException.class, () -> await(registry.create(definition(2))));
assertEquals("{invalid", Files.readString(file));
Files.write(file, healthy);
assertEquals(List.of(definition(1)), await(registry.reload()));
await(registry.create(definition(2)));
} finally { close(registry); }
}
@Test void invalidInitialFileDoesNotAppearReadyOrGetReplaced() throws Exception {
Path file = directory.resolve("npcs.json"); Files.writeString(file, "{invalid");
var registry = new VotingNPCRegistry(file);
try {
assertThrows(ExecutionException.class, () -> await(registry.initialLoad()));
assertFalse(registry.available());
assertThrows(ExecutionException.class, () -> await(registry.create(definition(1))));
assertEquals("{invalid", Files.readString(file));
} finally { close(registry); }
}
@Test void closeReturnsWhileAcceptedWriteDrainsAndKeepsExclusiveFileOwnership() throws Exception {
Path file = directory.resolve("npcs.json");
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
var store = new VotingNPCStore(file) {
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
entered.countDown(); waitOn(release); super.save(values);
}
};
var first = new VotingNPCRegistry(file, store);
VotingNPCRegistry replacement = null;
try {
await(first.initialLoad());
var writing = first.create(definition(1));
assertTrue(entered.await(5, TimeUnit.SECONDS));
first.close(); first.close();
assertFalse(first.awaitClosed(0, TimeUnit.SECONDS));
assertThrows(ExecutionException.class, () -> await(first.remove("npc-1")));
replacement = new VotingNPCRegistry(file);
var next = replacement;
assertThrows(ExecutionException.class, () -> await(next.initialLoad()));
assertFalse(replacement.available());
release.countDown(); await(writing);
assertTrue(first.awaitClosed(5, TimeUnit.SECONDS));
assertEquals(List.of(definition(1)), await(replacement.reload()));
} finally {
release.countDown(); close(first);
if (replacement != null) close(replacement);
}
}
@Test void saturatedReloadIsRejectedWithoutDisablingTheHealthyOwner() throws Exception {
Path file = directory.resolve("npcs.json");
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
var store = new VotingNPCStore(file) {
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
entered.countDown(); waitOn(release); super.save(values);
}
};
var registry = new VotingNPCRegistry(file, store);
try {
await(registry.initialLoad());
var writing = registry.create(definition(1));
assertTrue(entered.await(5, TimeUnit.SECONDS));
var queued = new java.util.ArrayList<CompletionStage<Boolean>>();
for (int i = 0; i < 128; i++) queued.add(registry.remove("missing"));
assertThrows(ExecutionException.class, () -> await(registry.reload()));
assertTrue(registry.available(), "rejected reload must leave existing readiness intact");
release.countDown(); await(writing);
for (var entry : queued) assertFalse(await(entry));
await(registry.create(definition(2)));
} finally { release.countDown(); close(registry); }
}
@Test void failedWriteFencesAlreadyQueuedMutationsUntilCheckedReload() throws Exception {
Path file = directory.resolve("npcs.json");
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
AtomicBoolean fail = new AtomicBoolean(true);
var store = new VotingNPCStore(file) {
@Override synchronized void save(List<VotingNPCDefinition> values) throws IOException {
if (fail.getAndSet(false)) {
entered.countDown(); waitOn(release);
super.save(values);
throw new IOException("Synthetic failure after visible write");
}
super.save(values);
}
};
var registry = new VotingNPCRegistry(file, store);
try {
await(registry.initialLoad());
var first = registry.create(definition(1));
assertTrue(entered.await(5, TimeUnit.SECONDS));
var second = registry.create(definition(2));
release.countDown();
assertThrows(ExecutionException.class, () -> await(first));
assertThrows(ExecutionException.class, () -> await(second));
assertFalse(registry.available());
assertEquals(List.of(), registry.snapshot());
assertEquals(List.of(definition(1)), new VotingNPCStore(file).load());
assertEquals(List.of(definition(1)), await(registry.reload()));
await(registry.create(definition(2)));
assertEquals(List.of(definition(1), definition(2)), registry.snapshot());
} finally { release.countDown(); close(registry); }
}
@Test void completionCallbackMayCloseWithoutDeadlockingOrInterruptingCommittedWork() throws Exception {
var registry = new VotingNPCRegistry(directory.resolve("npcs.json"));
try {
await(registry.initialLoad());
CompletableFuture<Void> callback = registry.create(definition(1)).thenAccept(value -> registry.close())
.toCompletableFuture();
await(callback);
assertTrue(registry.awaitClosed(5, TimeUnit.SECONDS));
assertEquals(List.of(definition(1)), new VotingNPCStore(directory.resolve("npcs.json")).load());
} finally { close(registry); }
}
@Test void capacityAndIdentityRejectionsLeaveHealthyStorageAvailable() throws Exception {
Path file = directory.resolve("npcs.json");
var seed = new VotingNPCStore(file); seed.load();
seed.save(java.util.stream.IntStream.rangeClosed(1, 63).mapToObj(this::definition).toList());
var registry = new VotingNPCRegistry(file);
try {
await(registry.initialLoad());
byte[] original = Files.readAllBytes(file);
var duplicateIdentity = new VotingNPCDefinition("another", definition(1).identity(), definition(1).world(),
0, 64, 0, 0, 0, "Vote");
assertThrows(ExecutionException.class, () -> await(registry.create(duplicateIdentity)));
assertTrue(registry.available(), "invalid new identity must not disable existing NPC storage");
assertArrayEquals(original, Files.readAllBytes(file));
await(registry.create(definition(64)));
byte[] atLimit = Files.readAllBytes(file);
assertThrows(ExecutionException.class, () -> await(registry.create(definition(65))));
assertTrue(registry.available(), "one extra creation must not disable 64 existing NPCs");
assertEquals(64, registry.snapshot().size());
assertArrayEquals(atLimit, Files.readAllBytes(file));
} finally { close(registry); }
}
}
@@ -1,78 +0,0 @@
package com.bencodez.votingplugin.npc;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
class VotingNPCSchedulerTest {
@Test void alreadyOwnedCleanupRunsInlineEvenDuringDisable() {
var plugin = mock(VotingPluginMain.class);
try (var bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
var scheduler = new VotingNPCScheduler(plugin); var ran = new AtomicInteger();
scheduler.entity(mock(Entity.class), ran::incrementAndGet, () -> fail("retired"), error -> fail(error));
assertEquals(1, ran.get()); verify(plugin, never()).getBukkitScheduler();
}
}
@Test void deferredRegionActionUsesTheLocationSchedulerAndReportsFailure() {
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
try (var bukkit = mockStatic(Bukkit.class)) {
var scheduler = new VotingNPCScheduler(plugin); Location anchor = mock(Location.class);
var completion = new CompletableFuture<Void>();
var callback = new AtomicReference<Runnable>();
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtLocation(eq(anchor), any()))
.thenAnswer(inv -> { Consumer<?> action = inv.getArgument(1); callback.set(() -> action.accept(null)); return completion; });
var ran = new AtomicInteger(); List<Throwable> failures = new ArrayList<>();
scheduler.region(anchor, ran::incrementAndGet, failures::add);
assertEquals(0, ran.get()); callback.get().run(); assertEquals(1, ran.get());
completion.completeExceptionally(new IllegalStateException("Synthetic scheduler failure"));
assertEquals(1, failures.size());
}
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void schedulerRetirementDoesNotPretendThatAnEntityWasRemoved() throws Exception {
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
try (var bukkit = mockStatic(Bukkit.class)) {
var scheduler = new VotingNPCScheduler(plugin); Entity entity = mock(Entity.class);
CompletableFuture completion = new CompletableFuture();
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(eq(entity), any(), any()))
.thenReturn(completion);
var ran = new AtomicInteger(); var retired = new AtomicInteger(); List<Throwable> failures = new ArrayList<>();
scheduler.entity(entity, ran::incrementAndGet, retired::incrementAndGet, failures::add);
completion.complete(Enum.valueOf((Class) Class.forName("com.bencodez.simpleapi.folialib.enums.EntityTaskResult"), "SCHEDULER_RETIRED"));
assertEquals(0, ran.get()); assertEquals(0, retired.get()); assertEquals(1, failures.size());
}
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void entityRetirementFallbackAndFutureCompletionAreHandledOnce() throws Exception {
var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
try (var bukkit = mockStatic(Bukkit.class)) {
var scheduler = new VotingNPCScheduler(plugin); Entity entity = mock(Entity.class);
CompletableFuture completion = new CompletableFuture(); var fallback = new AtomicReference<Runnable>();
when(plugin.getBukkitScheduler().getFoliaLib().getImpl().runAtEntityWithFallback(eq(entity), any(), any()))
.thenAnswer(inv -> { fallback.set(inv.getArgument(2)); return completion; });
var retired = new AtomicInteger();
scheduler.entity(entity, () -> fail("Entity already retired"), retired::incrementAndGet, error -> fail(error));
fallback.get().run();
completion.complete(Enum.valueOf((Class) Class.forName("com.bencodez.simpleapi.folialib.enums.EntityTaskResult"), "ENTITY_RETIRED"));
assertEquals(1, retired.get());
}
}
}
@@ -1,158 +0,0 @@
package com.bencodez.votingplugin.npc;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class VotingNPCStoreTest {
@TempDir Path directory;
private VotingNPCDefinition definition(int id) {
return new VotingNPCDefinition("npc-" + id, new UUID(0, id), new UUID(1, 0),
-0.5, 64, 16.5, 90, 0, "Vote");
}
@Test void roundTripPreservesStableIdentitiesAndPositionAcrossRestart() throws Exception {
Path file = directory.resolve("npcs.json");
var store = new VotingNPCStore(file);
assertEquals(List.of(), store.load());
store.save(List.of(definition(1), definition(2)));
assertEquals(List.of(definition(1), definition(2)), new VotingNPCStore(file).load());
assertEquals(-1, definition(1).chunkX());
assertEquals(1, definition(1).chunkZ());
}
@Test void missingInitialReadDoesNotCreateFilesAndUninitializedStoreCannotOverwrite() throws Exception {
Path file = directory.resolve("npcs.json");
var store = new VotingNPCStore(file);
assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
assertFalse(Files.exists(file));
assertTrue(store.load().isEmpty());
assertFalse(Files.exists(file));
}
@Test void invalidLoadKeepsOriginalBytesAndBlocksMutations() throws Exception {
Path file = directory.resolve("npcs.json");
byte[] original = "{ damaged definitions".getBytes(StandardCharsets.UTF_8);
Files.write(file, original);
var store = new VotingNPCStore(file);
assertThrows(IOException.class, store::load);
assertThrows(IOException.class, () -> store.save(List.of()));
assertArrayEquals(original, Files.readAllBytes(file));
}
@Test void duplicateIdentitiesAndCapacityRejectionDoNotChangeDurableDefinitions() throws Exception {
Path file = directory.resolve("npcs.json");
var store = new VotingNPCStore(file); store.load(); store.save(List.of(definition(1)));
byte[] before = Files.readAllBytes(file);
assertThrows(IOException.class, () -> store.save(List.of(definition(1), definition(1))));
var sameIdentity = new VotingNPCDefinition("another", definition(1).identity(), definition(1).world(),
0, 64, 0, 0, 0, "Vote");
assertThrows(IOException.class, () -> store.save(List.of(definition(1), sameIdentity)));
assertThrows(IOException.class, () -> store.save(IntStream.rangeClosed(1, 65).mapToObj(this::definition).toList()));
assertArrayEquals(before, Files.readAllBytes(file));
}
@Test void publicationFailureFencesOldStateUntilCheckedReload() throws Exception {
Path file = directory.resolve("npcs.json");
AtomicBoolean fail = new AtomicBoolean();
var store = new VotingNPCStore(file) {
@Override void forceDirectory(Path parent) throws IOException {
if (fail.get()) throw new IOException("Synthetic directory sync failure");
super.forceDirectory(parent);
}
};
store.load(); store.save(List.of(definition(1)));
fail.set(true);
assertThrows(IOException.class, () -> store.save(List.of(definition(1), definition(2))));
byte[] uncertain = Files.readAllBytes(file);
fail.set(false);
assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
assertArrayEquals(uncertain, Files.readAllBytes(file));
assertEquals(List.of(definition(1), definition(2)), store.load());
store.save(List.of(definition(2)));
assertEquals(List.of(definition(2)), new VotingNPCStore(file).load());
}
@Test void missingUncertainFileDoesNotBecomeAnEmptySuccessfulRecovery() throws Exception {
Path file = directory.resolve("npcs.json");
var store = new VotingNPCStore(file) {
@Override void forceDirectory(Path ignored) throws IOException { throw new IOException("Synthetic failure"); }
};
store.load(); assertThrows(IOException.class, () -> store.save(List.of(definition(1))));
Files.delete(file);
assertThrows(IOException.class, store::load);
assertThrows(IOException.class, () -> store.save(List.of()));
assertFalse(Files.exists(file));
}
@Test void strictFieldsAndTypesRejectMalformedDocumentsIndividually() throws Exception {
Path file = directory.resolve("npcs.json");
var store = new VotingNPCStore(file); store.load(); store.save(List.of(definition(1)));
String healthy = Files.readString(file);
for (String damaged : List.of(
healthy.replace("\"version\": 1", "\"version\": 1, \"version\": 1"),
healthy.replace("\"version\": 1", "\"version\": \"1\""),
healthy.replace("\"version\": 1", "\"version\": 1.0"),
healthy.replace("\"id\": \"npc-1\"", "\"id\": \"npc-1\", \"id\": \"other\""),
healthy.replace("\"x\": -0.5", "\"x\": \"-0.5\""),
healthy.replace("\"name\": \"Vote\"", "\"unknown\": \"Vote\""),
healthy.replace("\"name\": \"Vote\"", "\"name\": null"),
healthy.replace("\"yaw\": 90.0", "\"yaw\": 1e100"),
healthy + "{}")) {
Files.writeString(file, damaged);
assertThrows(IOException.class, () -> new VotingNPCStore(file).load(), damaged);
assertEquals(damaged, Files.readString(file));
}
}
@Test void invalidUtf8OversizeAndSymlinkCannotBeLoadedOrOverwritten() throws Exception {
Path file = directory.resolve("npcs.json");
Files.write(file, new byte[] {(byte) 0xc3, (byte) 0x28});
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
Files.write(file, new byte[128 * 1024 + 1]);
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
Files.delete(file);
Path target = directory.resolve("unrelated.json");
Files.writeString(target, "unrelated");
Files.createSymbolicLink(file, target);
assertThrows(IOException.class, () -> new VotingNPCStore(file).load());
assertEquals("unrelated", Files.readString(target));
}
@Test void linkedDirectoriesAllowDurableDefinitionsButLeafLinksStayRejected() throws Exception {
Path real = Files.createDirectory(directory.resolve("real"));
Path linked = Files.createSymbolicLink(directory.resolve("linked"), real);
Path nested = Files.createDirectory(real.resolve("data"));
for (Path parent : List.of(linked, linked.resolve("data"))) {
Path file = parent.resolve("npcs.json");
var store = new VotingNPCStore(file);
assertTrue(store.load().isEmpty());
store.save(List.of(definition(1)));
assertEquals(List.of(definition(1)), new VotingNPCStore(file).load());
}
Path leaf = linked.resolve("alias.json");
Files.createSymbolicLink(leaf, nested.resolve("npcs.json"));
assertThrows(IOException.class, () -> new VotingNPCStore(leaf).load());
assertEquals(List.of(definition(1)), new VotingNPCStore(nested.resolve("npcs.json")).load());
}
@Test void invalidDefinitionsAreRejectedBeforePublication() {
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("bad id", new UUID(0, 1),
new UUID(1, 0), 0, 64, 0, 0, 0, "Vote"));
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("valid", new UUID(0, 1),
new UUID(1, 0), Double.NaN, 64, 0, 0, 0, "Vote"));
assertThrows(IllegalArgumentException.class, () -> new VotingNPCDefinition("valid", new UUID(0, 1),
new UUID(1, 0), 0, 64, 0, 0, 0, "Vote\nNew line"));
}
}
@@ -0,0 +1,70 @@
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.config.Config;
import com.bencodez.votingplugin.maintenance.AutoCreateVoteSitesReminderService.Settings;
class AutoCreateVoteSitesReminderConfigTest {
@Test
void bundledConfigHasOnlyExistingAutoCreateSetting() throws Exception {
var resource = VotingPluginMain.class.getClassLoader().getResourceAsStream("Config.yml");
assertNotNull(resource);
try (var reader = new InputStreamReader(resource, StandardCharsets.UTF_8)) {
YamlConfiguration config = YamlConfiguration.loadConfiguration(reader);
assertTrue(config.getBoolean("AutoCreateVoteSites"));
assertFalse(config.contains("AutoCreateVoteSitesReminder"));
}
}
@Test
void enabledAutoCreationGetsStartupAndTwoDayNagWithoutExtraSettings() throws Exception {
Config config = mock(Config.class);
when(config.isAutoCreateVoteSites()).thenReturn(true);
Settings settings = settings(config);
assertTrue(settings.enabled());
assertTrue(settings.autoCreateEnabled());
assertEquals(0, settings.afterMillis());
assertEquals(TimeUnit.HOURS.toMillis(48), settings.repeatMillis());
verify(config, never()).getData();
}
@Test
void disablingExistingAutoCreateSettingStopsNag() throws Exception {
Config config = mock(Config.class);
when(config.isAutoCreateVoteSites()).thenReturn(false);
assertFalse(settings(config).autoCreateEnabled());
verify(config, never()).getData();
}
private static Settings settings(Config config) throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS);
Field field = VotingPluginMain.class.getDeclaredField("configFile");
field.setAccessible(true);
ReflectiveAccess.set(field, plugin, config);
Method method = VotingPluginMain.class.getDeclaredMethod("autoCreateVoteSitesReminderSettings");
method.setAccessible(true);
return (Settings) ReflectiveAccess.invoke(method, plugin, new Object[]{});
}
}
@@ -0,0 +1,270 @@
package com.bencodez.votingplugin.tests;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Candidate;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
class AutoCreateVoteSitesReminderStateTest {
@Test
void firstReminderUsesStartupThresholdAndFortyEightHourRepeatDefault() {
long threshold = AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS;
assertEquals(0, threshold);
assertEquals(TimeUnit.HOURS.toMillis(48), AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
AutoCreateVoteSitesReminderState state = fresh(0);
Candidate first = state.propose(0, true, true, threshold,
AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
assertTrue(first.reminderDue());
assertEquals(new Snapshot(0, 0), first.snapshot());
state.commit(first);
Candidate boundary = state.propose(nanos(AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS), true, true,
threshold, AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS);
assertTrue(boundary.reminderDue());
assertEquals(new Snapshot(AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS,
AutoCreateVoteSitesReminderState.DEFAULT_REPEAT_MILLIS), boundary.snapshot());
}
@Test
void disabledFlagsSuppressOnlyReminderAndKeepAccruingRuntime() {
boolean[][] disabled = { { false, false }, { false, true }, { true, false } };
for (boolean[] flags : disabled) {
AutoCreateVoteSitesReminderState state = fresh(0);
Candidate candidate = state.propose(nanos(1500), flags[0], flags[1], 1000, 500);
assertFalse(candidate.reminderDue());
assertEquals(new Snapshot(1500, -1), candidate.snapshot());
state.commit(candidate);
Candidate enabled = state.propose(nanos(1500), true, true, 1000, 500);
assertTrue(enabled.reminderDue());
assertEquals(new Snapshot(1500, 1500), enabled.snapshot());
}
}
@Test
void restartLoadsCumulativeRuntimeAndExcludesOfflineTime() {
AutoCreateVoteSitesReminderState previous = fresh(0);
previous.commit(previous.propose(nanos(900), false, false, 1000, 500));
long restartClock = nanos(TimeUnit.DAYS.toMillis(30));
AutoCreateVoteSitesReminderState restarted = new AutoCreateVoteSitesReminderState(previous.snapshot(),
restartClock);
assertFalse(restarted.propose(restartClock, true, true, 1000, 500).reminderDue());
Candidate before = restarted.propose(restartClock + nanos(99), true, true, 1000, 500);
assertEquals(new Snapshot(999, -1), before.snapshot());
assertFalse(before.reminderDue());
restarted.commit(before);
Candidate boundary = restarted.propose(restartClock + nanos(100), true, true, 1000, 500);
assertEquals(new Snapshot(1000, 1000), boundary.snapshot());
assertTrue(boundary.reminderDue());
}
@Test
void restartPreservesReminderCooldownWithFreshClockAnchor() {
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(1000, 1000),
-nanos(5000));
assertFalse(state.propose(-nanos(4501), true, true, 1000, 500).reminderDue());
assertTrue(state.propose(-nanos(4500), true, true, 1000, 500).reminderDue());
}
@Test
void repeatBoundaryIsInclusiveAndAdvancesOnlyAfterCommit() {
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(1000, 1000), 0);
Candidate before = state.propose(nanos(499), true, true, 1000, 500);
assertFalse(before.reminderDue());
state.commit(before);
Candidate boundary = state.propose(nanos(500), true, true, 1000, 500);
assertTrue(boundary.reminderDue());
assertEquals(new Snapshot(1499, 1000), state.snapshot());
state.commit(boundary);
assertEquals(new Snapshot(1500, 1500), state.snapshot());
assertFalse(state.propose(nanos(999), true, true, 1000, 500).reminderDue());
assertTrue(state.propose(nanos(1000), true, true, 1000, 500).reminderDue());
}
@Test
void failedPersistenceLeavesUptimeAndReminderRetryableWithoutDoubleCounting() {
AutoCreateVoteSitesReminderState state = fresh(0);
Candidate failedWrite = state.propose(nanos(1000), true, true, 1000, 500);
assertTrue(failedWrite.reminderDue());
// The storage owner must omit commit when its write fails.
assertEquals(new Snapshot(0, -1), state.snapshot());
Candidate sameClockRetry = state.propose(nanos(1000), true, true, 1000, 500);
assertEquals(failedWrite.snapshot(), sameClockRetry.snapshot());
Candidate laterRetry = state.propose(nanos(1200), true, true, 1000, 500);
assertTrue(laterRetry.reminderDue());
assertEquals(new Snapshot(1200, 1200), laterRetry.snapshot());
state.commit(laterRetry);
assertThrows(IllegalArgumentException.class, () -> state.commit(failedWrite));
assertThrows(IllegalArgumentException.class, () -> state.commit(sameClockRetry));
Candidate next = state.propose(nanos(1300), true, true, 1000, 500);
assertEquals(new Snapshot(1300, 1200), next.snapshot());
assertFalse(next.reminderDue());
}
@Test
void failedNonReminderCheckpointDoesNotMintRuntimeOnRetry() {
AutoCreateVoteSitesReminderState state = fresh(0);
state.propose(nanos(300), false, false, 1000, 500);
Candidate retry = state.propose(nanos(400), false, false, 1000, 500);
assertEquals(new Snapshot(400, -1), retry.snapshot());
state.commit(retry);
assertEquals(new Snapshot(500, -1), state.propose(nanos(500), false, false, 1000, 500).snapshot());
}
@Test
void staleAndDuplicateCommitsAreRejectedWithoutPublishingProgress() {
AutoCreateVoteSitesReminderState state = fresh(0);
Candidate first = state.propose(nanos(100), false, false, 1000, 500);
Candidate sibling = state.propose(nanos(200), false, false, 1000, 500);
state.commit(first);
assertThrows(IllegalArgumentException.class, () -> state.commit(first));
assertThrows(IllegalArgumentException.class, () -> state.commit(sibling));
assertEquals(new Snapshot(100, -1), state.snapshot());
Candidate retry = state.propose(nanos(200), false, false, 1000, 500);
state.commit(retry);
assertEquals(new Snapshot(200, -1), state.snapshot());
}
@Test
void candidateFromAnotherStateIsRejectedEvenWithIdenticalSnapshots() {
AutoCreateVoteSitesReminderState state = fresh(0);
AutoCreateVoteSitesReminderState other = fresh(0);
Candidate foreign = other.propose(nanos(1000), true, true, 1000, 500);
assertThrows(IllegalArgumentException.class, () -> state.commit(foreign));
assertEquals(new Snapshot(0, -1), state.snapshot());
other.commit(foreign);
assertEquals(new Snapshot(1000, 1000), other.snapshot());
}
@Test
void changingThresholdAndRepeatKeepsExistingRuntimeAndReminderMarker() {
AutoCreateVoteSitesReminderState state = fresh(0);
state.commit(state.propose(nanos(1500), true, true, 2000, 1000));
Candidate loweredThreshold = state.propose(nanos(1500), true, true, 1000, 1000);
assertTrue(loweredThreshold.reminderDue());
state.commit(loweredThreshold);
assertFalse(state.propose(nanos(2000), true, true, 3000, 100).reminderDue());
assertFalse(state.propose(nanos(2000), true, true, 1000, 1000).reminderDue());
Candidate loweredRepeat = state.propose(nanos(2000), true, true, 1000, 500);
assertTrue(loweredRepeat.reminderDue());
assertEquals(new Snapshot(2000, 2000), loweredRepeat.snapshot());
}
@Test
void disablingAfterReminderDoesNotClearItsMarker() {
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(1000, 1000), 0);
Candidate disabled = state.propose(nanos(600), false, false, 1000, 500);
state.commit(disabled);
assertEquals(new Snapshot(1600, 1000), state.snapshot());
assertFalse(disabled.reminderDue());
assertTrue(state.propose(nanos(600), true, true, 1000, 500).reminderDue());
}
@Test
void negativeNanoReadingsAccruePositiveElapsedRuntime() {
AutoCreateVoteSitesReminderState state = fresh(-nanos(1000));
Candidate candidate = state.propose(-nanos(900), false, false, 1000, 500);
assertEquals(new Snapshot(100, -1), candidate.snapshot());
state.commit(candidate);
assertEquals(new Snapshot(200, -1), state.propose(-nanos(800), false, false, 1000, 500).snapshot());
}
@Test
void signedNanoClockWrapAccruesElapsedTime() {
AutoCreateVoteSitesReminderState state = fresh(Long.MAX_VALUE - 500_000);
Candidate wrapped = state.propose(Long.MIN_VALUE + 499_999, true, true, 1, 1);
assertTrue(wrapped.reminderDue());
assertEquals(new Snapshot(1, 1), wrapped.snapshot());
state.commit(wrapped);
assertEquals(new Snapshot(2, 2), state.propose(Long.MIN_VALUE + 1_499_999, true, true, 1, 1).snapshot());
}
@Test
void backwardClockDoesNotMoveAnchorOrMintElapsedRuntime() {
AutoCreateVoteSitesReminderState state = fresh(nanos(1000));
Candidate backward = state.propose(nanos(900), true, true, 1000, 500);
assertEquals(new Snapshot(0, -1), backward.snapshot());
state.commit(backward);
assertEquals(new Snapshot(100, -1), state.propose(nanos(1100), false, false, 1000, 500).snapshot());
}
@Test
void subMillisecondCheckpointsRetainRemainderAndNeverDoubleCount() {
AutoCreateVoteSitesReminderState state = fresh(0);
for (long clock : new long[] { 400_000, 800_000, 1_200_000, 1_600_000, 2_000_000 }) {
state.commit(state.propose(clock, false, false, 1000, 500));
}
assertEquals(new Snapshot(2, -1), state.snapshot());
state.commit(state.propose(2_000_000, false, false, 1000, 500));
assertEquals(new Snapshot(2, -1), state.snapshot());
}
@Test
void runtimeSaturatesWithoutOverflowAndReminderMarkerRemainsValid() {
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(Long.MAX_VALUE - 2, -1), 0);
Candidate candidate = state.propose(nanos(10), true, true, 1000, 500);
assertTrue(candidate.reminderDue());
assertEquals(new Snapshot(Long.MAX_VALUE, Long.MAX_VALUE), candidate.snapshot());
state.commit(candidate);
Candidate later = state.propose(nanos(20), true, true, 1000, 500);
assertEquals(new Snapshot(Long.MAX_VALUE, Long.MAX_VALUE), later.snapshot());
assertFalse(later.reminderDue());
}
@Test
void saturationDoesNotOverflowRepeatElapsedComparison() {
AutoCreateVoteSitesReminderState state = new AutoCreateVoteSitesReminderState(new Snapshot(Long.MAX_VALUE - 1, 0), 0);
Candidate candidate = state.propose(nanos(100), true, true,
AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS, AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS);
assertTrue(candidate.reminderDue());
assertEquals(Long.MAX_VALUE, candidate.snapshot().totalRuntimeMillis());
}
@Test
void snapshotsRejectNegativeRuntimeInvalidSentinelAndFutureReminderMarker() {
assertThrows(IllegalArgumentException.class, () -> new Snapshot(-1, -1));
assertThrows(IllegalArgumentException.class, () -> new Snapshot(0, -2));
assertThrows(IllegalArgumentException.class, () -> new Snapshot(0, 1));
assertThrows(IllegalArgumentException.class, () -> new Snapshot(Long.MAX_VALUE - 1, Long.MAX_VALUE));
assertEquals(new Snapshot(0, -1), fresh(0).snapshot());
assertEquals(0, new Snapshot(0, 0).lastReminderRuntimeMillis());
assertEquals(Long.MAX_VALUE, new Snapshot(Long.MAX_VALUE, Long.MAX_VALUE).totalRuntimeMillis());
}
@Test
void intervalsAllowStartupThresholdButRequirePositiveBoundedRepeatsEvenWhileDisabled() {
AutoCreateVoteSitesReminderState state = fresh(0);
for (long invalid : new long[] { -1, AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS + 1, Long.MAX_VALUE }) {
assertThrows(IllegalArgumentException.class, () -> state.propose(0, false, false, invalid, 1));
assertThrows(IllegalArgumentException.class, () -> state.propose(0, false, false, 1, invalid));
}
assertThrows(IllegalArgumentException.class, () -> state.propose(0, false, false, 0, 0));
assertFalse(state.propose(0, false, false, 0, 1).reminderDue());
assertEquals(new Snapshot(0, -1), state.snapshot());
assertFalse(state.propose(0, true, true, AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS,
AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS).reminderDue());
}
@Test
void nullSnapshotAndNullCandidateAreRejected() {
assertThrows(NullPointerException.class, () -> new AutoCreateVoteSitesReminderState(null, 0));
AutoCreateVoteSitesReminderState state = fresh(0);
assertThrows(NullPointerException.class, () -> state.commit(null));
assertEquals(new Snapshot(0, -1), state.snapshot());
}
private static AutoCreateVoteSitesReminderState fresh(long nowNanos) {
return new AutoCreateVoteSitesReminderState(new Snapshot(0, AutoCreateVoteSitesReminderState.NEVER_REMINDED), nowNanos);
}
private static long nanos(long millis) {
return Math.multiplyExact(millis, 1_000_000);
}
}
@@ -1,5 +1,7 @@
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -1418,7 +1420,7 @@ class CachedVoteBatchDurabilityTest {
for (var pair : List.of(new Object[] {"useMySQL", true}, new Object[] {"voteCacheTable", serverTable},
new Object[] {"onlineVoteCacheTable", onlineTable}, new Object[] {"timedVoteCacheTable", timedTable})) {
Field field = VoteCacheHandler.class.getDeclaredField((String) pair[0]);
field.setAccessible(true); field.set(handler, pair[1]);
field.setAccessible(true); ReflectiveAccess.set(field, handler, pair[1]);
}
return handler;
}
@@ -1,5 +1,7 @@
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -63,7 +65,7 @@ class ProxyCacheCheckedReaderJdbcTest {
}
Field mysqlField = AbstractSqlTable.class.getDeclaredField("mysql");
mysqlField.setAccessible(true);
mysqlField.set(table, mysql);
ReflectiveAccess.set(mysqlField, table, mysql);
return new Fixture(checked, legacy, connection, statement, result);
}
@@ -1,4 +1,7 @@
package com.bencodez.votingplugin;
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.VotingPluginHooks;
import com.bencodez.votingplugin.VotingPluginMain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -0,0 +1,18 @@
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.*;
import com.bencodez.votingplugin.VotingPluginMain.*;
import java.util.UUID;
import org.bukkit.entity.Player;
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler;
/** Typed test-only access to package-private VotingPluginMain seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class VotingPluginMainAccess {
private VotingPluginMainAccess() { }
public static void closePublishedPreviousBackendProxyHandler(VotingPluginMain target, BackendProxyHandler previous, String phase) { ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target, "closePublishedPreviousBackendProxyHandler", new String[]{"BackendProxyHandler", "String"}, new Object[]{previous, phase}); }
public static void publishBackendProxyHandler(VotingPluginMain target, BackendProxyHandler previous, BackendProxyHandler replacement) { ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target, "publishBackendProxyHandler", new String[]{"BackendProxyHandler", "BackendProxyHandler"}, new Object[]{previous, replacement}); }
public static UUID placeholderStorageUuid(Player player, boolean onlineMode) { return (UUID) ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", null, "placeholderStorageUuid", new String[]{"Player", "boolean"}, new Object[]{player, onlineMode}); }
public static void reloadBackendProxyRuntime(VotingPluginMain target, boolean updateActiveRuntime, boolean userStorage) { ReflectiveAccess.call("com.bencodez.votingplugin.VotingPluginMain", target, "reloadBackendProxyRuntime", new String[]{"boolean", "boolean"}, new Object[]{updateActiveRuntime, userStorage}); }
}
@@ -1,4 +1,8 @@
package com.bencodez.votingplugin;
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.VotingPluginMain;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -44,7 +48,7 @@ class VotingPluginMainBackendProxyPublicationTest {
setField(plugin, "bungeeSettings", settings);
when(settings.isUseBungeecoord()).thenReturn(true);
plugin.reloadBackendProxyRuntime(true, false);
VotingPluginMainAccess.reloadBackendProxyRuntime(plugin, true, false);
verify(active).reloadSharedTransportSecurity();
verify(active).reloadPresenceReporting();
@@ -63,7 +67,7 @@ class VotingPluginMainBackendProxyPublicationTest {
// Full-editor Control preparation reloads settings before this replacement
// can be validated or published. It must not reconfigure the live handler.
plugin.reloadBackendProxyRuntime(false, false);
VotingPluginMainAccess.reloadBackendProxyRuntime(plugin, false, false);
VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement);
assertThrows(IllegalStateException.class, () -> plugin.completeBackendProxyHandlerRestart(restart));
@@ -79,7 +83,7 @@ class VotingPluginMainBackendProxyPublicationTest {
setField(plugin, "bungeeSettings", settings);
when(settings.isUseBungeecoord()).thenReturn(true);
plugin.reloadBackendProxyRuntime(false, false);
VotingPluginMainAccess.reloadBackendProxyRuntime(plugin, false, false);
assertNull(plugin.getBackendProxyHandler());
}
@@ -96,7 +100,7 @@ class VotingPluginMainBackendProxyPublicationTest {
Method load = VotingPluginMain.class.getDeclaredMethod("loadBungeeHandler");
load.setAccessible(true);
assertDoesNotThrow(() -> load.invoke(plugin));
assertDoesNotThrow(() -> ReflectiveAccess.invoke(load, plugin, new Object[]{}));
assertNull(plugin.getBackendProxyHandler());
}
@@ -157,7 +161,7 @@ class VotingPluginMainBackendProxyPublicationTest {
return null;
}).when(replacement).activatePresenceReporting();
plugin.publishBackendProxyHandler(null, replacement);
VotingPluginMainAccess.publishBackendProxyHandler(plugin, null, replacement);
assertSame(replacement, plugin.getBackendProxyHandler());
}
@@ -171,7 +175,7 @@ class VotingPluginMainBackendProxyPublicationTest {
.when(replacement).activatePresenceReporting();
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
() -> plugin.publishBackendProxyHandler(previous, replacement));
() -> VotingPluginMainAccess.publishBackendProxyHandler(plugin, previous, replacement));
assertSame(previous, plugin.getBackendProxyHandler());
verify(replacement).abortStagedInboundTo(previous);
@@ -268,7 +272,7 @@ class VotingPluginMainBackendProxyPublicationTest {
org.mockito.ArgumentMatchers.anyLong());
verify(previous, never()).close();
plugin.closePublishedPreviousBackendProxyHandler(previous, "after publication");
VotingPluginMainAccess.closePublishedPreviousBackendProxyHandler(plugin, previous, "after publication");
assertTrue(closed.await(1, TimeUnit.SECONDS));
assertTrue(closeThread.get().startsWith("VotingPlugin-Retired-Backend"));
}
@@ -332,7 +336,7 @@ class VotingPluginMainBackendProxyPublicationTest {
try {
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofMillis(250),
() -> plugin.closePublishedPreviousBackendProxyHandler(previous, "after publication"));
() -> VotingPluginMainAccess.closePublishedPreviousBackendProxyHandler(plugin, previous, "after publication"));
assertTrue(closeStarted.await(1, TimeUnit.SECONDS));
assertTrue(closeThread.get().startsWith("VotingPlugin-Retired-Backend"));
} finally {
@@ -705,7 +709,7 @@ class VotingPluginMainBackendProxyPublicationTest {
Constructor<VotingPluginMain.BackendProxyRestart> constructor = VotingPluginMain.BackendProxyRestart.class
.getDeclaredConstructor(BackendProxyHandler.class, BackendProxyHandler.class, boolean.class, boolean.class);
constructor.setAccessible(true);
return constructor.newInstance(previous, replacement, replacement == null, previousRequiresPreparation);
return ReflectiveAccess.newInstance(constructor, new Object[]{previous, replacement, replacement == null, previousRequiresPreparation});
}
private void setBackendProxyHandler(VotingPluginMain plugin, BackendProxyHandler handler) throws Exception {
@@ -715,13 +719,13 @@ class VotingPluginMainBackendProxyPublicationTest {
private void setField(VotingPluginMain plugin, String name, Object value) throws Exception {
Field field = VotingPluginMain.class.getDeclaredField(name);
field.setAccessible(true);
field.set(plugin, value);
ReflectiveAccess.set(field, plugin, value);
}
private void setRestartField(VotingPluginMain.BackendProxyRestart restart, String name, Object value)
throws Exception {
Field field = VotingPluginMain.BackendProxyRestart.class.getDeclaredField(name);
field.setAccessible(true);
field.set(restart, value);
ReflectiveAccess.set(field, restart, value);
}
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin;
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.VotingPluginMain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
@@ -156,7 +158,7 @@ class VotingPluginMainPlaceholderPresenceTest {
try (var uuidLookup = mockStatic(UuidLookup.class)) {
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
assertEquals(storageUuid, VotingPluginMain.placeholderStorageUuid(player, false));
assertEquals(storageUuid, VotingPluginMainAccess.placeholderStorageUuid(player, false));
}
}
@@ -171,7 +173,7 @@ class VotingPluginMainPlaceholderPresenceTest {
try (var uuidLookup = mockStatic(UuidLookup.class)) {
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, false));
assertEquals(bukkitUuid, VotingPluginMainAccess.placeholderStorageUuid(player, false));
}
}
@@ -184,7 +186,7 @@ class VotingPluginMainPlaceholderPresenceTest {
try (var uuidLookup = mockStatic(UuidLookup.class)) {
uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup);
assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, true));
assertEquals(bukkitUuid, VotingPluginMainAccess.placeholderStorageUuid(player, true));
}
}
}
@@ -1,6 +1,8 @@
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
@@ -245,7 +247,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true);
var field = VotingPluginProxy.class.getDeclaredField("communicationEncryption");
field.setAccessible(true);
field.set(votingPluginProxy, encryption);
ReflectiveAccess.set(field, votingPluginProxy, encryption);
return encryption;
}
@@ -384,7 +386,7 @@ public class VotingPluginProxyTest {
Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class));
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
decider.setAccessible(true);
decider.set(votingPluginProxy, new ProxyBroadcastDecider(votingPluginProxy::getConfig,
ReflectiveAccess.set(decider, votingPluginProxy, new ProxyBroadcastDecider(votingPluginProxy::getConfig,
votingPluginProxy::getAllAvailableServers, server -> true, server -> false));
java.util.UUID voteId = java.util.UUID.randomUUID();
@@ -711,7 +713,7 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
for (java.util.Map.Entry<String, Object> field : java.util.Map.<String, Object>of(
"player", "Player", "service", "Service", "uuid",
"00000000-0000-0000-0000-000000000001", "time", 100L, "realVote", true,
@@ -730,11 +732,9 @@ public class VotingPluginProxyTest {
retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class,
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
@SuppressWarnings({ "rawtypes", "unchecked" })
org.mockito.ArgumentCaptor<java.util.Collection<String>> recipients =
(org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(java.util.Collection.class);
@@ -744,7 +744,7 @@ public class VotingPluginProxyTest {
java.lang.reflect.Method settle = VotingPluginProxy.class
.getDeclaredMethod("settleLiveVoteRetriesForFinalShutdown");
settle.setAccessible(true);
assertFalse((Boolean) settle.invoke(spyProxy));
assertFalse((Boolean) ReflectiveAccess.invoke(settle, spyProxy, new Object[]{}));
org.mockito.ArgumentCaptor<VoteTimeQueue> outboxes = org.mockito.ArgumentCaptor.forClass(VoteTimeQueue.class);
verify(voteCache, Mockito.atLeast(2)).addTimeVoteToCache(outboxes.capture());
@@ -767,7 +767,7 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.util.UUID voteId = java.util.UUID.randomUUID();
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
retriesField.setAccessible(true);
@@ -780,8 +780,7 @@ public class VotingPluginProxyTest {
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(votingPluginProxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, votingPluginProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
org.mockito.ArgumentCaptor<JsonEnvelope> envelope = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class);
verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(envelope.capture(),
Mockito.eq(java.util.Set.of("ProxyLegacy")));
@@ -822,7 +821,7 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.util.UUID voteId = java.util.UUID.randomUUID();
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
retriesField.setAccessible(true);
@@ -835,8 +834,7 @@ public class VotingPluginProxyTest {
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(proxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
assertEquals(1, queue.size());
assertTrue(queue.element().isMultiProxyCapabilityDiscoveryPending());
@@ -844,15 +842,13 @@ public class VotingPluginProxyTest {
verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L),
Mockito.eq(java.util.concurrent.TimeUnit.SECONDS));
assertTrue((Boolean) begin.invoke(proxy, retry, queue.element(), "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertTrue((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, queue.element(), "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.eq(java.util.Set.of("Candidate")));
assertFalse(queue.element().isMultiProxyCapabilityDiscoveryPending());
assertTrue(queue.element().isMultiProxyForwardingHandled(),
"the durable discovery fence must be retired before reporting the legacy fallback complete");
assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty());
assertTrue((Boolean) begin.invoke(proxy, retry, queue.element(), "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertTrue((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, queue.element(), "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
verify(multiProxyHandler, Mockito.times(1)).sendMultiProxyEnvelopeAccepted(Mockito.any(),
Mockito.eq(java.util.Set.of("Candidate")));
verify(multiProxyHandler).renewMultiProxyVoteCapabilityDiscoveryIfDue();
@@ -872,14 +868,13 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding",
retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class,
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(votingPluginProxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, votingPluginProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
}
@@ -893,14 +888,13 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding",
retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class,
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
verify(multiProxyHandler, never()).announceMultiProxyVoteCapability();
verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any());
verify(voteCache, never()).addTimeVoteToCache(Mockito.any());
@@ -928,7 +922,7 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.util.UUID voteId = java.util.UUID.randomUUID();
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
retriesField.setAccessible(true);
@@ -941,8 +935,7 @@ public class VotingPluginProxyTest {
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, spyProxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
assertEquals(1, queue.size());
assertEquals(java.util.Set.of("capable"), queue.element().getMultiProxyRecipients());
assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty());
@@ -975,7 +968,7 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.util.UUID voteId = java.util.UUID.randomUUID();
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
retriesField.setAccessible(true);
@@ -988,8 +981,7 @@ public class VotingPluginProxyTest {
boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class);
begin.setAccessible(true);
assertFalse((Boolean) begin.invoke(proxy, retry, null, "Player",
"00000000-0000-0000-0000-000000000001", "Service", 100L, true, null));
assertFalse((Boolean) ReflectiveAccess.invoke(begin, proxy, new Object[]{retry, null, "Player", "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null}));
assertEquals(1, queue.size());
verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L),
Mockito.eq(java.util.concurrent.TimeUnit.SECONDS));
@@ -1096,7 +1088,7 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> constructor = retryType.getDeclaredConstructor();
constructor.setAccessible(true);
Object retry = constructor.newInstance();
Object retry = ReflectiveAccess.newInstance(constructor, new Object[]{});
java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
retriesField.setAccessible(true);
@SuppressWarnings("unchecked")
@@ -1176,17 +1168,17 @@ public class VotingPluginProxyTest {
Class<?> retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState");
java.lang.reflect.Constructor<?> retryConstructor = retryType.getDeclaredConstructor();
retryConstructor.setAccessible(true);
Object retry = retryConstructor.newInstance();
Object retry = ReflectiveAccess.newInstance(retryConstructor, new Object[]{});
java.lang.reflect.Method finish = VotingPluginProxy.class.getDeclaredMethod("finishMultiProxyRetirement",
retryType, VoteTimeQueue.class);
finish.setAccessible(true);
assertFalse((Boolean) finish.invoke(spyProxy, retry, outbox));
assertFalse((Boolean) ReflectiveAccess.invoke(finish, spyProxy, new Object[]{retry, outbox}));
assertFalse(outbox.isMultiProxyForwardingHandled());
verify(multiProxyHandler).requestMultiProxyVoteRetirement(voteId, "Proxy1", "proxy2");
assertTrue(outbox.acknowledgeMultiProxyRetirement("Proxy2"));
assertTrue((Boolean) finish.invoke(spyProxy, retry, outbox));
assertTrue((Boolean) ReflectiveAccess.invoke(finish, spyProxy, new Object[]{retry, outbox}));
assertTrue(outbox.isMultiProxyForwardingHandled());
verify(voteCache).updateTimeVote(outbox);
}
@@ -1234,11 +1226,11 @@ public class VotingPluginProxyTest {
"handleMultiProxyVoteRetirementRequest", java.util.UUID.class, String.class);
retire.setAccessible(true);
retire.invoke(spyProxy, voteId, "Primary");
ReflectiveAccess.invoke(retire, spyProxy, new Object[]{voteId, "Primary"});
verify(voteCache, never()).removeMultiProxyVoteCompletion(voteId);
verify(multiProxyHandler, never()).acknowledgeMultiProxyVoteRetirement(voteId, "Primary");
retire.invoke(spyProxy, voteId, "Primary");
ReflectiveAccess.invoke(retire, spyProxy, new Object[]{voteId, "Primary"});
verify(voteCache).removeMultiProxyVoteCompletion(voteId);
verify(multiProxyHandler).acknowledgeMultiProxyVoteRetirement(voteId, "Primary");
}
@@ -1271,7 +1263,7 @@ public class VotingPluginProxyTest {
Mockito.doNothing().when(spyProxy).addVoteParty();
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
decider.setAccessible(true);
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
server -> true, server -> false));
spyProxy.processQueue();
@@ -1320,7 +1312,7 @@ public class VotingPluginProxyTest {
.thenReturn(new java.util.ArrayList<>());
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
decider.setAccessible(true);
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
server -> true, server -> false));
spyProxy.processQueue();
@@ -1368,7 +1360,7 @@ public class VotingPluginProxyTest {
Mockito.when(proxyMySQL.getExactQuery(Mockito.any())).thenReturn(new java.util.ArrayList<>());
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
decider.setAccessible(true);
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
server -> true, server -> false));
spyProxy.processQueue();
@@ -1445,7 +1437,7 @@ public class VotingPluginProxyTest {
Mockito.doNothing().when(spyProxy).addVoteParty();
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
decider.setAccessible(true);
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
server -> true, server -> false));
spyProxy.processQueue();
@@ -1465,7 +1457,7 @@ public class VotingPluginProxyTest {
OfflineBungeeVote.class, boolean.class, boolean.class, int.class, int.class);
envelopeMethod.setAccessible(true);
JsonEnvelope envelope = (JsonEnvelope) envelopeMethod.invoke(votingPluginProxy, cached, true, false, 1, 1);
JsonEnvelope envelope = (JsonEnvelope) ReflectiveAccess.invoke(envelopeMethod, votingPluginProxy, new Object[]{cached, true, false, 1, 1});
VotingPluginWire.Vote vote = VotingPluginWire.readVote(envelope);
assertTrue(vote.queuedDeliveryKnown);
@@ -1483,8 +1475,7 @@ public class VotingPluginProxyTest {
OfflineBungeeVote.class, boolean.class, boolean.class, int.class, int.class);
envelopeMethod.setAccessible(true);
VotingPluginWire.Vote vote = VotingPluginWire.readVote((JsonEnvelope) envelopeMethod.invoke(
votingPluginProxy, cached, true, false, 1, 1));
VotingPluginWire.Vote vote = VotingPluginWire.readVote((JsonEnvelope) ReflectiveAccess.invoke(envelopeMethod, votingPluginProxy, new Object[]{cached, true, false, 1, 1}));
assertFalse(vote.queuedDeliveryKnown);
assertFalse(vote.delayValidationKnown);
@@ -1514,8 +1505,7 @@ public class VotingPluginProxyTest {
String.class, boolean.class, boolean.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Primary", true, true);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Primary", true, true});
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(spyProxy.getLastVoteEnvelope());
assertTrue(forwarded.delayValidationKnown);
@@ -1546,8 +1536,7 @@ public class VotingPluginProxyTest {
String.class, boolean.class, boolean.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false});
VotingPluginWire.Vote forwarded = VotingPluginWire.readVote(spyProxy.getLastVoteEnvelope());
assertFalse(forwarded.delayValidationKnown);
@@ -1580,8 +1569,7 @@ public class VotingPluginProxyTest {
String.class, boolean.class, boolean.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID(), "Legacy", false, false});
org.mockito.ArgumentCaptor<OfflineBungeeVote> cached =
org.mockito.ArgumentCaptor.forClass(OfflineBungeeVote.class);
@@ -1612,8 +1600,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
org.mockito.ArgumentCaptor<Runnable> scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class);
verify(scheduler, Mockito.atLeastOnce()).schedule(scheduled.capture(), Mockito.anyLong(),
Mockito.any(java.util.concurrent.TimeUnit.class));
@@ -1623,8 +1610,7 @@ public class VotingPluginProxyTest {
+ scheduled.getAllValues().stream().map(task -> task.getClass().getName()).toList()));
voteRetry.run();
// A duplicate socket/Redis delivery of the completed envelope is ignored.
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
verify(spyProxy).addVoteParty();
java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
@@ -1654,8 +1640,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
org.mockito.ArgumentCaptor<Runnable> scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).schedule(scheduled.capture(), Mockito.anyLong(),
Mockito.any(java.util.concurrent.TimeUnit.class));
@@ -1681,8 +1666,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
verify(spyProxy, never()).addVoteParty();
}
@@ -1709,8 +1693,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", false, true, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", false, true, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
assertEquals(1, queue.size());
assertEquals(voteId, queue.element().getVoteId());
@@ -1790,8 +1773,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
spyProxy.onDisable(false);
verify(spyProxy).addVoteParty();
@@ -1825,8 +1807,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", voteId);
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", voteId});
spyProxy.onDisable(false);
java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries");
@@ -1895,7 +1876,7 @@ public class VotingPluginProxyTest {
Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler();
java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider");
decider.setAccessible(true);
decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
ReflectiveAccess.set(decider, spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers,
server -> true, server -> false));
Mockito.doNothing().when(spyProxy).addVoteParty();
Mockito.doReturn(true).when(spyProxy).checkVoteDelay(Mockito.anyString(), Mockito.anyString(),
@@ -1931,8 +1912,7 @@ public class VotingPluginProxyTest {
com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class);
receive.setAccessible(true);
receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null,
"00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID());
ReflectiveAccess.invoke(receive, spyProxy, new Object[]{"Player", "Service", true, false, 100L, null, "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID()});
java.lang.reflect.Field liveRetries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries");
liveRetries.setAccessible(true);
@@ -2830,7 +2810,7 @@ public class VotingPluginProxyTest {
java.lang.reflect.Method start = VotingPluginProxy.class.getDeclaredMethod("startHttpTransportOrFallBack");
start.setAccessible(true);
start.invoke(spyProxy);
ReflectiveAccess.invoke(start, spyProxy, new Object[]{});
assertEquals(BungeeMethod.PLUGINMESSAGING, spyProxy.getMethod());
assertFalse(java.nio.file.Files.exists(retained));
@@ -2849,7 +2829,7 @@ public class VotingPluginProxyTest {
.deleteRetainedHttpListenerSettings(retained);
java.lang.reflect.Method start = VotingPluginProxy.class.getDeclaredMethod("startHttpTransportOrFallBack");
start.setAccessible(true);
start.invoke(spyProxy);
ReflectiveAccess.invoke(start, spyProxy, new Object[]{});
assertEquals(BungeeMethod.PLUGINMESSAGING, spyProxy.getMethod());
assertTrue(java.nio.file.Files.isRegularFile(retained));
spyProxy.reloadFromControl();
@@ -2867,12 +2847,12 @@ public class VotingPluginProxyTest {
java.lang.reflect.Method start = VotingPluginProxy.class.getDeclaredMethod("startHttpTransportOrFallBack");
start.setAccessible(true);
start.invoke(votingPluginProxy);
ReflectiveAccess.invoke(start, votingPluginProxy, new Object[]{});
Object handler = getProxyField(votingPluginProxy, "encryptionHandler");
assertTrue(handler != null);
votingPluginProxy.setMethod(BungeeMethod.HTTP);
start.invoke(votingPluginProxy);
ReflectiveAccess.invoke(start, votingPluginProxy, new Object[]{});
assertSame(handler, getProxyField(votingPluginProxy, "encryptionHandler"));
}
@@ -2887,7 +2867,7 @@ public class VotingPluginProxyTest {
Mockito.when(votingPluginProxy.getConfig().getHttpPublicEndpoint()).thenReturn("");
java.lang.reflect.InvocationTargetException failure = assertThrows(
java.lang.reflect.InvocationTargetException.class, () -> start.invoke(votingPluginProxy), configured.name());
java.lang.reflect.InvocationTargetException.class, () -> ReflectiveAccess.invoke(start, votingPluginProxy, new Object[]{}), configured.name());
assertTrue(failure.getCause() instanceof IllegalStateException, configured.name());
assertEquals(BungeeMethod.HTTP, votingPluginProxy.getMethod(), configured.name());
@@ -3457,7 +3437,7 @@ public class VotingPluginProxyTest {
java.lang.reflect.Method probe = VotingPluginProxy.class
.getDeclaredMethod("probeReliableVoteDeliveryCapabilities");
probe.setAccessible(true);
probe.invoke(spyProxy);
ReflectiveAccess.invoke(probe, spyProxy, new Object[]{});
org.mockito.ArgumentCaptor<String> target = org.mockito.ArgumentCaptor.forClass(String.class);
org.mockito.ArgumentCaptor<JsonEnvelope> envelope = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class);
@@ -4107,7 +4087,7 @@ public class VotingPluginProxyTest {
private static void setProxyField(VotingPluginProxy target, String name, Object value) throws Exception {
java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
ReflectiveAccess.set(field, target, value);
}
private static Object getProxyField(VotingPluginProxy target, String name) throws Exception {
@@ -1,5 +1,7 @@
package com.bencodez.votingplugin.tests;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
@@ -496,7 +498,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy {
HttpProxyTransportServer.DeliveryRetryException.class
.getDeclaredConstructor(String.class, Throwable.class);
constructor.setAccessible(true);
throw constructor.newInstance(generatedHttpRetryId, new IllegalStateException("publication ambiguous"));
throw ReflectiveAccess.newInstance(constructor, new Object[]{generatedHttpRetryId, new IllegalStateException("publication ambiguous")});
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
}
@@ -557,7 +559,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy {
HttpProxyTransportServer.DeliveryRetryException.class
.getDeclaredConstructor(String.class, Throwable.class);
constructor.setAccessible(true);
return constructor.newInstance(deliveryId, new IllegalStateException("publication ambiguous"));
return ReflectiveAccess.newInstance(constructor, new Object[]{deliveryId, new IllegalStateException("publication ambiguous")});
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
}
@@ -628,7 +630,7 @@ public class VotingPluginProxyTestImpl extends VotingPluginProxy {
try {
java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField("globalMessageProxyHandler");
field.setAccessible(true);
field.set(this, handler);
ReflectiveAccess.set(field, this, handler);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
@@ -0,0 +1,36 @@
package com.bencodez.votingplugin.tests.backendproxy;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.*;
import com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue.*;
import java.util.function.Consumer;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
/** Typed test-only access to package-private BackendOrderedVoteOverflowQueue seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendOrderedVoteOverflowQueueAccess {
public static final int MAX_NORMAL_ENTRIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", "MAX_NORMAL_ENTRIES");
public static final int MAX_FAILED_ENTRIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", "MAX_FAILED_ENTRIES");
private BackendOrderedVoteOverflowQueueAccess() { }
public static boolean enqueue(BackendOrderedVoteOverflowQueue target, JsonEnvelope envelope) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "enqueue", new String[]{"JsonEnvelope"}, new Object[]{envelope}); }
public static boolean enqueue(BackendOrderedVoteOverflowQueue target, JsonEnvelope envelope, int reservedPrefixEntries) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "enqueue", new String[]{"JsonEnvelope", "int"}, new Object[]{envelope, reservedPrefixEntries}); }
public static int failedSize(BackendOrderedVoteOverflowQueue target) { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "failedSize", new String[]{}, new Object[]{}); }
public static boolean isQuarantineCapacityExhausted(BackendOrderedVoteOverflowQueue target) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "isQuarantineCapacityExhausted", new String[]{}, new Object[]{}); }
public static boolean quarantineForShutdown(BackendOrderedVoteOverflowQueue target, BackendOrderedVoteOverflowQueueAccess.PendingEnvelope expected, JsonEnvelope envelope) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "quarantineForShutdown", new String[]{"PendingEnvelope", "JsonEnvelope"}, new Object[]{expected, envelope}); }
public static void quarantineAsync(BackendOrderedVoteOverflowQueue target, BackendOrderedVoteOverflowQueueAccess.PendingEnvelope expected, JsonEnvelope envelope, Consumer<Boolean> completion) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "quarantineAsync", new String[]{"PendingEnvelope", "JsonEnvelope", "Consumer"}, new Object[]{expected, envelope, completion}); }
public static BackendOrderedVoteOverflowQueueAccess.PendingEnvelope peekDurable(BackendOrderedVoteOverflowQueue target) { return BackendOrderedVoteOverflowQueueAccess.PendingEnvelope.view(ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "peekDurable", new String[]{}, new Object[]{})); }
public static boolean awaitClose(BackendOrderedVoteOverflowQueue target, long timeoutMillis) throws InterruptedException { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "awaitClose", new String[]{"long"}, new Object[]{timeoutMillis}); }
public static BackendOrderedVoteOverflowQueueAccess.CloseState closeState(BackendOrderedVoteOverflowQueue target) { return ReflectiveAccess.enumView(BackendOrderedVoteOverflowQueueAccess.CloseState.class, ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue", target, "closeState", new String[]{}, new Object[]{})); }
public enum CloseState { OPEN, CLOSING, WAITING_TO_PERSIST, SNAPSHOT_REPLACED, COMPLETE, FAILED }
public static final class PendingEnvelope implements ReflectiveAccess.View {
private final Object delegate;
private PendingEnvelope(Object delegate) { this.delegate = delegate; }
public static PendingEnvelope view(Object delegate) { return delegate == null ? null : new PendingEnvelope(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof PendingEnvelope value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public JsonEnvelope envelope() { return (JsonEnvelope) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue$PendingEnvelope", delegate, "envelope", new String[]{}, new Object[]{}); }
}
}
@@ -0,0 +1,15 @@
package com.bencodez.votingplugin.tests.backendproxy;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.*;
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler.*;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
/** Typed test-only access to package-private BackendProxyHandler seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendProxyHandlerAccess {
public static void dispatchIncomingAfterPublication(BackendProxyHandler target, JsonEnvelope envelope, Runnable localDispatch) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.BackendProxyHandler", target, "dispatchIncomingAfterPublication", new String[]{"JsonEnvelope", "Runnable"}, new Object[]{envelope, localDispatch}); }
public static final int MAX_ORDERED_VOTE_QUEUE = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.BackendProxyHandler", "MAX_ORDERED_VOTE_QUEUE");
private BackendProxyHandlerAccess() { }
}
@@ -1,4 +1,10 @@
package com.bencodez.votingplugin.backendproxy;
package com.bencodez.votingplugin.tests.backendproxy;
import com.bencodez.votingplugin.tests.backendproxy.cache.ProcessedVoteCacheAccess;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.BackendOrderedVoteOverflowQueue;
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -197,7 +203,7 @@ class BackendProxyHandlerLifecycleTest {
TransportEnvelopeEncryption.Domain.PROXY_BACKEND, false));
BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager");
BackendProxyTransport transport = method == BungeeMethod.REDIS
? new RedisBackendProxyTransport(plugin, new ProcessedVoteCache())
? new RedisBackendProxyTransport(plugin, ProcessedVoteCacheAccess.create())
: new MqttBackendProxyTransport(plugin);
setField(transport, "authenticator", SharedTransportEnvelopeAuthenticator.load(keyFile,
Mode.COMPATIBILITY));
@@ -233,7 +239,7 @@ class BackendProxyHandlerLifecycleTest {
setField(handler, "method", BungeeMethod.REDIS);
setField(handler, "sharedTransportMode", Mode.COMPATIBILITY);
setField(handler, "communicationEncryptionEnabled", false);
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, ProcessedVoteCacheAccess.create());
SharedTransportEnvelopeAuthenticator original = SharedTransportEnvelopeAuthenticator.load(
dataDirectory.resolve("secretkey.key"), Mode.COMPATIBILITY);
setField(transport, "authenticator", original);
@@ -267,7 +273,7 @@ class BackendProxyHandlerLifecycleTest {
setField(handler, "communicationEncryption", originalEncryption);
BackendProxyTransportManager manager = (BackendProxyTransportManager) getField(handler, "transportManager");
BackendProxyTransport transport = method == BungeeMethod.REDIS
? new RedisBackendProxyTransport(plugin, new ProcessedVoteCache())
? new RedisBackendProxyTransport(plugin, ProcessedVoteCacheAccess.create())
: new MqttBackendProxyTransport(plugin);
SharedTransportEnvelopeAuthenticator originalAuthenticator =
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED);
@@ -298,7 +304,7 @@ class BackendProxyHandlerLifecycleTest {
handler.activateInboundMessages();
Runnable localDispatch = mock(Runnable.class);
handler.dispatchIncomingAfterPublication(
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler,
JsonEnvelope.builder(VotingPluginWire.SUB_BUNGEE_TIME_CHANGE).build(), localDispatch);
verify(scheduler).runTaskAsynchronously(plugin, localDispatch);
@@ -338,9 +344,9 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.dispatchIncomingAfterPublication(voteEnvelope, mock(Runnable.class));
handler.dispatchIncomingAfterPublication(updateEnvelope, mock(Runnable.class));
handler.dispatchIncomingAfterPublication(onlineEnvelope, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, voteEnvelope, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, updateEnvelope, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, onlineEnvelope, mock(Runnable.class));
assertEquals(1, asyncTasks.size(), "only the head of the ordered vote lane should be scheduled");
@@ -391,9 +397,9 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(eq(release), any());
handler.dispatchIncomingAfterPublication(blockedVote, mock(Runnable.class));
handler.dispatchIncomingAfterPublication(release, mock(Runnable.class));
handler.dispatchIncomingAfterPublication(secondRelease, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, blockedVote, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, release, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, secondRelease, mock(Runnable.class));
assertEquals(2, asyncTasks.size(), "only one durable release should use the bounded release worker");
asyncTasks.removeLast().run();
@@ -419,7 +425,7 @@ class BackendProxyHandlerLifecycleTest {
doThrow(new java.util.concurrent.RejectedExecutionException("stopping"))
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
handler.dispatchIncomingAfterPublication(release, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, release, mock(Runnable.class));
verify(router, never()).handleOrderedVote(eq(release), any());
AtomicBoolean active = (AtomicBoolean) getField(handler, "durableReceiptReleaseActive");
@@ -482,8 +488,8 @@ class BackendProxyHandlerLifecycleTest {
"survival", UUID.randomUUID(), VotingPluginWire.SUB_VOTE);
when(router.isValidReceiptRelease(release)).thenReturn(true);
handler.dispatchIncomingAfterPublication(olderVote, mock(Runnable.class));
handler.dispatchIncomingAfterPublication(release, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, olderVote, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, release, mock(Runnable.class));
@SuppressWarnings("unchecked")
ArrayDeque<JsonEnvelope> queued = (ArrayDeque<JsonEnvelope>) getField(handler, "orderedVoteDispatchQueue");
@@ -521,7 +527,7 @@ class BackendProxyHandlerLifecycleTest {
VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "site", 10L,
true, true, "", voteId, false, false, 1, 1));
handler.dispatchIncomingAfterPublication(envelope, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, envelope, mock(Runnable.class));
dispatch.get().run();
verifyNoInteractions(messages);
completion.get().accept(OrderedVoteOutcome.COMPLETE);
@@ -560,7 +566,7 @@ class BackendProxyHandlerLifecycleTest {
VotingPluginWire.voteDelayRejected("Player", UUID.randomUUID().toString(),
"site", true, voteId));
handler.dispatchIncomingAfterPublication(envelope, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, envelope, mock(Runnable.class));
dispatch.get().run();
verifyNoInteractions(messages);
completion.get().accept(OrderedVoteOutcome.COMPLETE);
@@ -580,19 +586,19 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-overflow-test"));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
setField(handler, "messageRouter", mock(BackendProxyMessageRouter.class));
handler.activateInboundMessages();
for (int i = 0; i < BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE + 1; i++) {
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
for (int i = 0; i < BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE + 1; i++) {
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put(VotingPluginWire.K_VOTE_ID, new java.util.UUID(0L, i + 1L).toString()).build(),
mock(Runnable.class));
}
@SuppressWarnings("unchecked")
ArrayDeque<JsonEnvelope> queued = (ArrayDeque<JsonEnvelope>) getField(handler, "orderedVoteDispatchQueue");
assertEquals(BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE, queued.size());
assertEquals(BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE, queued.size());
assertEquals(1, overflow.size());
} finally {
overflow.close();
@@ -650,14 +656,14 @@ class BackendProxyHandlerLifecycleTest {
blocker.start();
assertTrue(locked.await(3, TimeUnit.SECONDS));
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
setField(handler, "messageRouter", mock(BackendProxyMessageRouter.class));
handler.activateInboundMessages();
for (int index = 0; index < BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE; index++) {
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
for (int index = 0; index < BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE; index++) {
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", Integer.toString(index)).build(), mock(Runnable.class));
}
CompletableFuture<Void> admission = CompletableFuture.runAsync(() -> handler.dispatchIncomingAfterPublication(
CompletableFuture<Void> admission = CompletableFuture.runAsync(() -> BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler,
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "overflow").build(),
mock(Runnable.class)));
admission.get(500, TimeUnit.MILLISECONDS);
@@ -684,7 +690,7 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-unreadable-test"));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
} finally {
overflow.close();
}
@@ -705,7 +711,7 @@ class BackendProxyHandlerLifecycleTest {
Files.writeString(queueFile, malformed);
BackendOrderedVoteOverflowQueue malformedQueue = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertFalse(malformedQueue.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(malformedQueue, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
} finally {
malformedQueue.close();
}
@@ -719,7 +725,7 @@ class BackendProxyHandlerLifecycleTest {
Files.writeString(queueFile, unsupported);
BackendOrderedVoteOverflowQueue unsupportedQueue = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertFalse(unsupportedQueue.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(unsupportedQueue, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
} finally {
unsupportedQueue.close();
}
@@ -734,7 +740,7 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
Files.createDirectory(tempDir.resolve("BackendProxyVoteQueue.yml"));
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertEquals(0, overflow.size());
} finally {
overflow.close();
@@ -744,7 +750,7 @@ class BackendProxyHandlerLifecycleTest {
@Test
void oversizedFailureHistoryStopsRecoveryWithoutDiscardingEvidence(@TempDir Path tempDir) throws Exception {
StringBuilder yaml = new StringBuilder("Envelopes: []\nFailedEnvelopes:\n");
for (int index = 0; index < BackendOrderedVoteOverflowQueue.MAX_FAILED_ENTRIES + 20; index++) {
for (int index = 0; index < BackendOrderedVoteOverflowQueueAccess.MAX_FAILED_ENTRIES + 20; index++) {
yaml.append("- failed-").append(index).append('\n');
}
Files.writeString(tempDir.resolve("BackendProxyVoteQueue.yml"), yaml);
@@ -753,8 +759,8 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-failure-bound-test"));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(0, overflow.failedSize());
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertEquals(0, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertTrue(Files.readString(tempDir.resolve("BackendProxyVoteQueue.yml")).contains("failed-0"));
} finally {
overflow.close();
@@ -764,7 +770,7 @@ class BackendProxyHandlerLifecycleTest {
@Test
void fullFailureHistoryRejectsNextQuarantineWithoutEviction(@TempDir Path tempDir) throws Exception {
StringBuilder yaml = new StringBuilder("Envelopes: []\nFailedEnvelopes:\n");
for (int index = 0; index < BackendOrderedVoteOverflowQueue.MAX_FAILED_ENTRIES; index++) {
for (int index = 0; index < BackendOrderedVoteOverflowQueueAccess.MAX_FAILED_ENTRIES; index++) {
yaml.append("- failed-").append(index).append('\n');
}
Files.writeString(tempDir.resolve("BackendProxyVoteQueue.yml"), yaml);
@@ -773,10 +779,10 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-failure-capacity-test"));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertFalse(overflow.quarantineForShutdown(null, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
assertFalse(BackendOrderedVoteOverflowQueueAccess.quarantineForShutdown(overflow, null, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "rejected").build()));
assertEquals(BackendOrderedVoteOverflowQueue.MAX_FAILED_ENTRIES, overflow.failedSize());
assertTrue(overflow.isQuarantineCapacityExhausted());
assertEquals(BackendOrderedVoteOverflowQueueAccess.MAX_FAILED_ENTRIES, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertTrue(BackendOrderedVoteOverflowQueueAccess.isQuarantineCapacityExhausted(overflow));
@SuppressWarnings("unchecked")
ArrayDeque<String> failures = (ArrayDeque<String>) getField(overflow, "failedEntries");
assertEquals("failed-0", failures.peekFirst());
@@ -784,9 +790,9 @@ class BackendProxyHandlerLifecycleTest {
BukkitScheduler scheduler = mock(BukkitScheduler.class);
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
handler.activateInboundMessages();
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
mock(Runnable.class));
verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class));
} finally {
@@ -804,19 +810,19 @@ class BackendProxyHandlerLifecycleTest {
UUID.randomUUID().toString(), "site", 10L, true, true, "", voteId, false, false, 1, 1));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertTrue(overflow.quarantineForShutdown(null, vote));
assertTrue(BackendOrderedVoteOverflowQueueAccess.quarantineForShutdown(overflow, null, vote));
long firstDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (overflow.failedSize() != 1 && System.nanoTime() < firstDeadline) Thread.sleep(10L);
assertEquals(1, overflow.failedSize());
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) != 1 && System.nanoTime() < firstDeadline) Thread.sleep(10L);
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertTrue(overflow.enqueue(vote));
BackendOrderedVoteOverflowQueue.PendingEnvelope retry = overflow.peekDurable();
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, vote));
BackendOrderedVoteOverflowQueueAccess.PendingEnvelope retry = BackendOrderedVoteOverflowQueueAccess.peekDurable(overflow);
assertNotNull(retry);
CompletableFuture<Boolean> quarantined = new CompletableFuture<>();
overflow.quarantineAsync(retry, vote, quarantined::complete);
BackendOrderedVoteOverflowQueueAccess.quarantineAsync(overflow, retry, vote, quarantined::complete);
assertTrue(quarantined.get(3, TimeUnit.SECONDS));
assertEquals(1, overflow.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertEquals(0, overflow.size());
} finally {
overflow.close();
@@ -824,7 +830,7 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(1, recovered.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
assertEquals(0, recovered.size());
} finally {
recovered.close();
@@ -845,7 +851,7 @@ class BackendProxyHandlerLifecycleTest {
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
AtomicInteger attempts = new AtomicInteger();
@@ -856,9 +862,9 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "failed").build()));
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "next").build()));
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
assertNotNull(first);
@@ -870,7 +876,7 @@ class BackendProxyHandlerLifecycleTest {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (overflow.size() != 0 && System.nanoTime() < deadline) Thread.sleep(10);
assertEquals(0, overflow.size());
assertEquals(1, overflow.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertTrue(Files.exists(tempDir.resolve("BackendProxyVoteQueue.yml")));
} finally {
overflow.close();
@@ -878,7 +884,7 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(0, recovered.size());
assertEquals(1, recovered.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
} finally {
recovered.close();
}
@@ -898,22 +904,22 @@ class BackendProxyHandlerLifecycleTest {
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
doThrow(new IllegalStateException("partial reward")).when(router)
.handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "failed").build(), mock(Runnable.class));
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
assertNotNull(first);
assertThrows(IllegalStateException.class, first::run);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (overflow.failedSize() == 0 && System.nanoTime() < deadline) {
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) == 0 && System.nanoTime() < deadline) {
Thread.sleep(10);
}
assertEquals(1, overflow.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertEquals(0, overflow.size());
assertTrue(asyncTasks.isEmpty(), "failed reward must not be retried");
} finally {
@@ -947,13 +953,13 @@ class BackendProxyHandlerLifecycleTest {
});
assertTrue(occupied.await(3, TimeUnit.SECONDS));
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
doThrow(new IllegalStateException("partial reward")).when(router)
.handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "failed").build(), mock(Runnable.class));
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
assertNotNull(first);
@@ -966,7 +972,7 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(0, recovered.size());
assertEquals(1, recovered.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
} finally {
recovered.close();
}
@@ -980,29 +986,29 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-capacity-test"));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
handler.activateInboundMessages();
for (int index = 0; index < BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE; index++) {
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
for (int index = 0; index < BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE; index++) {
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", Integer.toString(index)).build(), mock(Runnable.class));
}
for (int index = 0; index < BackendOrderedVoteOverflowQueue.MAX_NORMAL_ENTRIES; index++) {
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", Integer.toString(index + BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE))
.build(), BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE));
for (int index = 0; index < BackendOrderedVoteOverflowQueueAccess.MAX_NORMAL_ENTRIES; index++) {
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", Integer.toString(index + BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE))
.build(), BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE));
}
assertFalse(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE));
assertFalse(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(),
BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE));
handler.close();
assertEquals(BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE
+ BackendOrderedVoteOverflowQueue.MAX_NORMAL_ENTRIES, overflow.size());
assertEquals(BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE
+ BackendOrderedVoteOverflowQueueAccess.MAX_NORMAL_ENTRIES, overflow.size());
} finally {
overflow.close();
}
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE
+ BackendOrderedVoteOverflowQueue.MAX_NORMAL_ENTRIES, recovered.size());
assertEquals(BackendProxyHandlerAccess.MAX_ORDERED_VOTE_QUEUE
+ BackendOrderedVoteOverflowQueueAccess.MAX_NORMAL_ENTRIES, recovered.size());
} finally {
recovered.close();
}
@@ -1022,7 +1028,7 @@ class BackendProxyHandlerLifecycleTest {
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
AtomicInteger attempts = new AtomicInteger();
@@ -1033,7 +1039,7 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build()));
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build()));
Runnable first = asyncTasks.poll(3, TimeUnit.SECONDS);
assertNotNull(first, "durable head must wake the ordered lane");
@@ -1067,7 +1073,7 @@ class BackendProxyHandlerLifecycleTest {
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
doAnswer(invocation -> {
@@ -1076,7 +1082,7 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()));
asyncTasks.poll(3, TimeUnit.SECONDS).run();
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (overflow.size() != 0 && System.nanoTime() < deadline) Thread.sleep(10);
@@ -1118,11 +1124,11 @@ class BackendProxyHandlerLifecycleTest {
try {
overflow.close();
assertTrue(System.nanoTime() - started < TimeUnit.SECONDS.toNanos(4));
assertEquals(BackendOrderedVoteOverflowQueue.CloseState.WAITING_TO_PERSIST, overflow.closeState());
assertEquals(BackendOrderedVoteOverflowQueueAccess.CloseState.WAITING_TO_PERSIST, BackendOrderedVoteOverflowQueueAccess.closeState(overflow));
} finally {
release.countDown();
blocker.join(3_000L);
assertTrue(overflow.awaitClose(3_000L));
assertTrue(BackendOrderedVoteOverflowQueueAccess.awaitClose(overflow, 3_000L));
}
}
@@ -1137,7 +1143,7 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getDataFolder()).thenReturn(tempDir.toFile());
when(plugin.getLogger()).thenReturn(logger);
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "slow").build()));
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "slow").build()));
Object persistenceLock = getField(overflow, "persistenceWriteLock");
CountDownLatch locked = new CountDownLatch(1);
Thread blocker = new Thread(() -> {
@@ -1156,7 +1162,7 @@ class BackendProxyHandlerLifecycleTest {
overflow.close();
blocker.join(3_000L);
assertTrue(System.nanoTime() - started >= TimeUnit.SECONDS.toNanos(1));
assertEquals(BackendOrderedVoteOverflowQueue.CloseState.COMPLETE, overflow.closeState());
assertEquals(BackendOrderedVoteOverflowQueueAccess.CloseState.COMPLETE, BackendOrderedVoteOverflowQueueAccess.closeState(overflow));
assertFalse(logs.hasLevel(Level.SEVERE));
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
@@ -1183,7 +1189,7 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
overflow.close();
assertEquals(BackendOrderedVoteOverflowQueue.CloseState.FAILED, overflow.closeState());
assertEquals(BackendOrderedVoteOverflowQueueAccess.CloseState.FAILED, BackendOrderedVoteOverflowQueueAccess.closeState(overflow));
assertTrue(logs.hasLevel(Level.SEVERE));
LogRecord failure = logs.records.stream()
.filter(record -> record.getLevel() == Level.SEVERE)
@@ -1223,9 +1229,9 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
.put("sequence", "first").build(), mock(Runnable.class));
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "second").build(), mock(Runnable.class));
asyncTasks.removeFirst().run();
@@ -1246,12 +1252,12 @@ class BackendProxyHandlerLifecycleTest {
when(plugin.getLogger()).thenReturn(Logger.getLogger("ordered-shutdown-test"));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyTransportManager transport = mock(BackendProxyTransportManager.class);
setField(handler, "transportManager", transport);
handler.activateInboundMessages();
doAnswer(invocation -> {
handler.dispatchIncomingAfterPublication(
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler,
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build(), mock(Runnable.class));
return null;
}).when(transport).close();
@@ -1283,7 +1289,7 @@ class BackendProxyHandlerLifecycleTest {
}).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
AtomicReference<java.util.function.Consumer<OrderedVoteOutcome>> completion = new AtomicReference<>();
@@ -1292,9 +1298,9 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "active").build(), mock(Runnable.class));
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "later").build(), mock(Runnable.class));
asyncTasks.removeFirst().run();
CompletableFuture<Void> closing = CompletableFuture.runAsync(handler::close);
@@ -1323,7 +1329,7 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
AtomicReference<java.util.function.Consumer<OrderedVoteOutcome>> completion = new AtomicReference<>();
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
doAnswer(invocation -> {
@@ -1331,26 +1337,26 @@ class BackendProxyHandlerLifecycleTest {
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
handler.activateInboundMessages();
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "active").build(), mock(Runnable.class));
handler.dispatchIncomingAfterPublication(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "later").build(), mock(Runnable.class));
asyncTasks.removeFirst().run();
CompletableFuture.runAsync(handler::close).get(3, TimeUnit.SECONDS);
long quarantineDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (overflow.failedSize() != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
assertEquals(1, overflow.failedSize());
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertEquals(1, overflow.size());
completion.get().accept(OrderedVoteOutcome.COMPLETE);
assertEquals(1, overflow.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertEquals(1, overflow.size());
} finally {
overflow.close();
}
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(1, recovered.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
assertEquals(1, recovered.size());
} finally {
recovered.close();
@@ -1372,32 +1378,32 @@ class BackendProxyHandlerLifecycleTest {
BackendOrderedVoteOverflowQueue overflow = new BackendOrderedVoteOverflowQueue(plugin);
AtomicReference<java.util.function.Consumer<OrderedVoteOutcome>> completion = new AtomicReference<>();
try {
BackendProxyHandler handler = new BackendProxyHandler(plugin, new ProcessedVoteCache(), overflow);
BackendProxyHandler handler = new BackendProxyHandler(plugin, ProcessedVoteCacheAccess.create(), overflow);
BackendProxyMessageRouter router = mock(BackendProxyMessageRouter.class);
setField(handler, "messageRouter", router);
doAnswer(invocation -> {
completion.set(invocation.getArgument(1));
return null;
}).when(router).handleOrderedVote(any(JsonEnvelope.class), any());
assertTrue(overflow.enqueue(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
assertTrue(BackendOrderedVoteOverflowQueueAccess.enqueue(overflow, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE)
.put("sequence", "active").build()));
handler.activateInboundMessages();
asyncTasks.removeFirst().run();
CompletableFuture.runAsync(handler::close).get(3, TimeUnit.SECONDS);
long quarantineDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (overflow.failedSize() != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
assertEquals(1, overflow.failedSize());
while (BackendOrderedVoteOverflowQueueAccess.failedSize(overflow) != 1 && System.nanoTime() < quarantineDeadline) Thread.sleep(10L);
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertEquals(0, overflow.size());
completion.get().accept(OrderedVoteOutcome.COMPLETE);
assertEquals(1, overflow.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(overflow));
assertEquals(0, overflow.size());
} finally {
overflow.close();
}
BackendOrderedVoteOverflowQueue recovered = new BackendOrderedVoteOverflowQueue(plugin);
try {
assertEquals(1, recovered.failedSize());
assertEquals(1, BackendOrderedVoteOverflowQueueAccess.failedSize(recovered));
assertEquals(0, recovered.size());
} finally {
recovered.close();
@@ -1419,12 +1425,12 @@ class BackendProxyHandlerLifecycleTest {
JsonEnvelope first = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("sequence", "1").build();
JsonEnvelope second = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).put("sequence", "2").build();
previous.dispatchIncomingAfterPublication(first, mock(Runnable.class));
previous.dispatchIncomingAfterPublication(second, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(previous, first, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(previous, second, mock(Runnable.class));
previous.completeOrderedVoteHandoff(replacement);
JsonEnvelope late = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_ONLINE).put("sequence", "3").build();
previous.dispatchIncomingAfterPublication(late, mock(Runnable.class));
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(previous, late, mock(Runnable.class));
ArrayDeque<Runnable> asyncTasks = new ArrayDeque<>();
doAnswer(invocation -> {
@@ -1498,7 +1504,7 @@ class BackendProxyHandlerLifecycleTest {
CompletableFuture<Void> callback = CompletableFuture.runAsync(() -> {
started.countDown();
handler.dispatchIncomingAfterPublication(envelope, localDispatch);
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(handler, envelope, localDispatch);
});
assertTrue(started.await(1, TimeUnit.SECONDS));
assertFalse(callback.isDone());
@@ -1524,7 +1530,7 @@ class BackendProxyHandlerLifecycleTest {
CompletableFuture<Void> callback = CompletableFuture.runAsync(() -> {
started.countDown();
replacement.dispatchIncomingAfterPublication(envelope, replacementDispatch);
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(replacement, envelope, replacementDispatch);
});
assertTrue(started.await(1, TimeUnit.SECONDS));
assertFalse(callback.isDone());
@@ -1556,7 +1562,7 @@ class BackendProxyHandlerLifecycleTest {
CompletableFuture<Void> callback = CompletableFuture.runAsync(() -> {
started.countDown();
replacement.dispatchIncomingAfterPublication(envelope, replacementDispatch);
BackendProxyHandlerAccess.dispatchIncomingAfterPublication(replacement, envelope, replacementDispatch);
});
assertTrue(started.await(1, TimeUnit.SECONDS));
assertFalse(callback.isDone());
@@ -1608,7 +1614,7 @@ class BackendProxyHandlerLifecycleTest {
try {
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
transportField.setAccessible(true);
transportField.set(manager, previous);
ReflectiveAccess.set(transportField, manager, previous);
manager.restoreAfterFailedReplacement();
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
@@ -1677,7 +1683,7 @@ class BackendProxyHandlerLifecycleTest {
Field targetField = com.bencodez.votingplugin.VotingPluginMain.class
.getDeclaredField("backendPluginMessageTarget");
targetField.setAccessible(true);
targetField.set(plugin, new AtomicReference<GlobalMessageHandler>());
ReflectiveAccess.set(targetField, plugin, new AtomicReference<GlobalMessageHandler>());
plugin.activateBackendPluginMessageHandler(firstTarget);
current.set(second);
@@ -1864,7 +1870,7 @@ class BackendProxyHandlerLifecycleTest {
BackendPresenceManager presence = mock(BackendPresenceManager.class);
Field field = BackendProxyHandler.class.getDeclaredField("presenceManager");
field.setAccessible(true);
field.set(handler, presence);
ReflectiveAccess.set(field, handler, presence);
verifyNoInteractions(presence);
handler.activatePresenceReporting();
@@ -1880,7 +1886,7 @@ class BackendProxyHandlerLifecycleTest {
org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException()).when(presence).start();
Field presenceField = BackendProxyHandler.class.getDeclaredField("presenceManager");
presenceField.setAccessible(true);
presenceField.set(handler, presence);
ReflectiveAccess.set(presenceField, handler, presence);
Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager");
managerField.setAccessible(true);
@@ -1888,7 +1894,7 @@ class BackendProxyHandlerLifecycleTest {
BackendProxyTransport transport = mock(BackendProxyTransport.class);
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
transportField.setAccessible(true);
transportField.set(manager, transport);
ReflectiveAccess.set(transportField, manager, transport);
assertThrows(java.util.concurrent.RejectedExecutionException.class, handler::activatePresenceReporting);
@@ -1898,7 +1904,7 @@ class BackendProxyHandlerLifecycleTest {
@Test
void sharesVoteDeduplicationAcrossHandlerReplacement() {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
BackendProxyHandler previous = new BackendProxyHandler(null, cache);
BackendProxyHandler replacement = new BackendProxyHandler(null, cache);
@@ -1917,7 +1923,7 @@ class BackendProxyHandlerLifecycleTest {
BackendProxyTransport transport = mock(BackendProxyTransport.class);
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
transportField.setAccessible(true);
transportField.set(manager, transport);
ReflectiveAccess.set(transportField, manager, transport);
handler.prepareForReplacement(BungeeMethod.PLUGINMESSAGING);
@@ -1930,7 +1936,7 @@ class BackendProxyHandlerLifecycleTest {
SocketHandler socket = mock(SocketHandler.class);
Field field = SocketBackendProxyTransport.class.getDeclaredField("socketHandler");
field.setAccessible(true);
field.set(handler, socket);
ReflectiveAccess.set(field, handler, socket);
handler.prepareForReplacement();
@@ -2017,10 +2023,10 @@ class BackendProxyHandlerLifecycleTest {
RedisHandler redis = mock(RedisHandler.class);
Field field = RedisBackendProxyTransport.class.getDeclaredField("redisHandler");
field.setAccessible(true);
field.set(transport, redis);
ReflectiveAccess.set(field, transport, redis);
Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport");
transportField.setAccessible(true);
transportField.set(manager, transport);
ReflectiveAccess.set(transportField, manager, transport);
handler.prepareForReplacement(BungeeMethod.REDIS);
@@ -2034,7 +2040,7 @@ class BackendProxyHandlerLifecycleTest {
MqttHandler mqtt = mock(MqttHandler.class);
Field field = MqttBackendProxyTransport.class.getDeclaredField("mqttHandler");
field.setAccessible(true);
field.set(handler, mqtt);
ReflectiveAccess.set(field, handler, mqtt);
handler.prepareForReplacement();
@@ -2766,7 +2772,7 @@ class BackendProxyHandlerLifecycleTest {
MySqlMessenger messenger = mock(MySqlMessenger.class);
Field field = MysqlBackendProxyTransport.class.getDeclaredField("messenger");
field.setAccessible(true);
field.set(handler, messenger);
ReflectiveAccess.set(field, handler, messenger);
handler.prepareForReplacement();
@@ -2911,7 +2917,7 @@ class BackendProxyHandlerLifecycleTest {
ScheduledExecutorService timer = mock(ScheduledExecutorService.class);
Field field = BackendGlobalDataSync.class.getDeclaredField("timer");
field.setAccessible(true);
field.set(handler, timer);
ReflectiveAccess.set(field, handler, timer);
handler.close();
@@ -2944,7 +2950,7 @@ class BackendProxyHandlerLifecycleTest {
try {
Field field = type.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
ReflectiveAccess.set(field, target, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
@@ -0,0 +1,36 @@
package com.bencodez.votingplugin.tests.backendproxy.cache;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.cache.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
/** Test-only forwarding access to the non-public production DurableVoteReceiptStore. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class DurableVoteReceiptStoreAccess implements ReflectiveAccess.View {
public static final int MAX_ACTIVE_RECEIPTS = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_ACTIVE_RECEIPTS");
public static final int COMPLETION_HEADROOM = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "COMPLETION_HEADROOM");
public static final int MAX_RELEASE_TOMBSTONES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_RELEASE_TOMBSTONES");
public static final long MAX_FILE_BYTES = (Long) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_FILE_BYTES");
public static final int MAX_RECORD_BYTES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_RECORD_BYTES");
public static final int MAX_TOTAL_RECEIPTS = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", "MAX_TOTAL_RECEIPTS");
private final Object delegate;
private DurableVoteReceiptStoreAccess(Object delegate) { this.delegate = delegate; }
public static DurableVoteReceiptStoreAccess view(Object delegate) { return delegate == null ? null : new DurableVoteReceiptStoreAccess(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof DurableVoteReceiptStoreAccess value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public static DurableVoteReceiptStoreAccess mock() { return view(org.mockito.Mockito.mock(ReflectiveAccess.type("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore"))); }
public DurableVoteReceiptStoreAccess verify() { return view(org.mockito.Mockito.verify(delegate)); }
public DurableVoteReceiptStoreAccess verify(org.mockito.verification.VerificationMode mode) { return view(org.mockito.Mockito.verify(delegate, mode)); }
public DurableVoteReceiptStoreAccess stub(org.mockito.stubbing.Stubber stubber) { return view(stubber.when(delegate)); }
public DurableVoteReceiptStoreAccess(Path file) throws IOException { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", new String[]{"Path"}, new Object[]{file})); }
public DurableVoteReceiptStoreAccess(Path file, int maxActiveReceipts, int completionHeadroom, int maxReleaseTombstones) throws IOException { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", new String[]{"Path", "int", "int", "int"}, new Object[]{file, maxActiveReceipts, completionHeadroom, maxReleaseTombstones})); }
public DurableVoteReceiptStoreAccess(Path file, int maxActiveReceipts, int completionHeadroom, int maxReleaseTombstones, int maxTotalReceipts) throws IOException { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", new String[]{"Path", "int", "int", "int", "int"}, new Object[]{file, maxActiveReceipts, completionHeadroom, maxReleaseTombstones, maxTotalReceipts})); }
public Map<UUID, Long> snapshot() { return (Map<UUID, Long>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "snapshot", new String[]{}, new Object[]{}); }
public boolean contains(UUID voteId) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "contains", new String[]{"UUID"}, new Object[]{voteId}); }
public long complete(UUID voteId) { return (Long) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "complete", new String[]{"UUID"}, new Object[]{voteId}); }
public long release(UUID voteId) { return (Long) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.cache.DurableVoteReceiptStore", delegate, "release", new String[]{"UUID"}, new Object[]{voteId}); }
}
@@ -0,0 +1,19 @@
package com.bencodez.votingplugin.tests.backendproxy.cache;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.cache.*;
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache.*;
import java.nio.file.Path;
/** Typed test-only access to package-private ProcessedVoteCache seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class ProcessedVoteCacheAccess {
public static final int DEFAULT_MAX_TRACKED_VOTES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", "DEFAULT_MAX_TRACKED_VOTES");
private ProcessedVoteCacheAccess() { }
public static ProcessedVoteCache create() { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{}, new Object[]{}); }
public static ProcessedVoteCache create(long ttlMillis) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long"}, new Object[]{ttlMillis}); }
public static ProcessedVoteCache create(Path receiptFile) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"Path"}, new Object[]{receiptFile}); }
public static ProcessedVoteCache create(long ttlMillis, Path receiptFile) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long", "Path"}, new Object[]{ttlMillis, receiptFile}); }
public static ProcessedVoteCache create(long ttlMillis, int maxTrackedVotes) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long", "int"}, new Object[]{ttlMillis, maxTrackedVotes}); }
public static ProcessedVoteCache create(long ttlMillis, int maxTrackedVotes, DurableVoteReceiptStoreAccess durableReceipts) { return (ProcessedVoteCache) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache", new String[]{"long", "int", "DurableVoteReceiptStore"}, new Object[]{ttlMillis, maxTrackedVotes, durableReceipts}); }
}
@@ -1,4 +1,7 @@
package com.bencodez.votingplugin.backendproxy.cache;
package com.bencodez.votingplugin.tests.backendproxy.cache;
import com.bencodez.votingplugin.tests.backendproxy.cache.DurableVoteReceiptStoreAccess;
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -16,7 +19,7 @@ class ProcessedVoteCacheDurabilityTest {
@Test
void duplicateRetryDoesNotScanAndPruneUnrelatedEntries() {
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 2);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 2);
UUID expired = UUID.randomUUID();
UUID live = UUID.randomUUID();
cache.getProcessedVotes().put(expired, 0L);
@@ -29,7 +32,7 @@ class ProcessedVoteCacheDurabilityTest {
@Test
void expiredHeadRetryReclaimsItsSlotBeforeSaturationCheck() {
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 1);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 1);
UUID expired = UUID.randomUUID();
cache.getProcessedVotes().put(expired, 0L);
@@ -44,44 +47,44 @@ class ProcessedVoteCacheDurabilityTest {
void releaseTombstoneCapacityCoversSustainedVoteThroughputForFullTtl() {
long threeVotesPerSecondForOneDay = TimeUnit.DAYS.toSeconds(1) * 3;
assertTrue(DurableVoteReceiptStore.MAX_RELEASE_TOMBSTONES >= threeVotesPerSecondForOneDay);
assertTrue(DurableVoteReceiptStoreAccess.MAX_RELEASE_TOMBSTONES >= threeVotesPerSecondForOneDay);
}
@Test
void completedVoteRemainsDeduplicatedAfterBackendRestart() {
Path receipts = directory.resolve("receipts.dat");
UUID voteId = UUID.randomUUID();
ProcessedVoteCache first = new ProcessedVoteCache(receipts);
ProcessedVoteCache first = ProcessedVoteCacheAccess.create(receipts);
assertTrue(first.reserve(voteId));
assertTrue(first.complete(voteId));
assertTrue(first.hasDurableReceipt(voteId));
assertFalse(new ProcessedVoteCache(receipts).reserve(voteId));
assertFalse(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
}
@Test
void reservationWithoutCompletionDoesNotSuppressRestartRetry() {
Path receipts = directory.resolve("receipts.dat");
UUID voteId = UUID.randomUUID();
assertTrue(new ProcessedVoteCache(receipts).reserve(voteId));
assertTrue(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
assertTrue(new ProcessedVoteCache(receipts).reserve(voteId));
assertTrue(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
}
@Test
void repairsTruncatedTailBeforeAppendingAnotherReceipt() throws Exception {
Path receipts = directory.resolve("receipts.dat");
UUID firstId = UUID.randomUUID();
ProcessedVoteCache first = new ProcessedVoteCache(receipts);
ProcessedVoteCache first = ProcessedVoteCacheAccess.create(receipts);
assertTrue(first.reserve(firstId));
assertTrue(first.complete(firstId));
Files.writeString(receipts, "truncated", StandardOpenOption.APPEND);
ProcessedVoteCache repaired = new ProcessedVoteCache(receipts);
ProcessedVoteCache repaired = ProcessedVoteCacheAccess.create(receipts);
UUID secondId = UUID.randomUUID();
assertTrue(repaired.reserve(secondId));
assertTrue(repaired.complete(secondId));
ProcessedVoteCache restarted = new ProcessedVoteCache(receipts);
ProcessedVoteCache restarted = ProcessedVoteCacheAccess.create(receipts);
assertFalse(restarted.reserve(firstId));
assertFalse(restarted.reserve(secondId));
}
@@ -91,14 +94,14 @@ class ProcessedVoteCacheDurabilityTest {
Path receipts = directory.resolve("receipts.dat");
UUID firstId = UUID.randomUUID();
UUID secondId = UUID.randomUUID();
ProcessedVoteCache cache = new ProcessedVoteCache(receipts);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(receipts);
assertTrue(cache.reserve(firstId));
assertTrue(cache.complete(firstId));
Files.writeString(receipts, "partial", StandardOpenOption.APPEND);
assertTrue(cache.reserve(secondId));
assertTrue(cache.complete(secondId));
ProcessedVoteCache restarted = new ProcessedVoteCache(receipts);
ProcessedVoteCache restarted = ProcessedVoteCacheAccess.create(receipts);
assertFalse(restarted.reserve(firstId));
assertFalse(restarted.reserve(secondId));
}
@@ -109,7 +112,7 @@ class ProcessedVoteCacheDurabilityTest {
UUID incompleteId = UUID.randomUUID();
Files.writeString(receipts, "VP-VOTE-RECEIPTS-1\n" + incompleteId + "\t9");
ProcessedVoteCache repaired = new ProcessedVoteCache(receipts);
ProcessedVoteCache repaired = ProcessedVoteCacheAccess.create(receipts);
assertTrue(repaired.reserve(incompleteId));
assertTrue(Files.readString(receipts).endsWith("\n"));
@@ -120,7 +123,7 @@ class ProcessedVoteCacheDurabilityTest {
Path unusableParent = directory.resolve("not-a-directory");
Files.writeString(unusableParent, "file");
UUID voteId = UUID.randomUUID();
ProcessedVoteCache cache = new ProcessedVoteCache(1L, unusableParent.resolve("receipts.dat"));
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(1L, unusableParent.resolve("receipts.dat"));
assertTrue(cache.reserve(voteId));
assertFalse(cache.complete(voteId));
@@ -132,31 +135,31 @@ class ProcessedVoteCacheDurabilityTest {
void proxyConfirmedReleaseLeavesRestartSafeTombstone() {
Path receipts = directory.resolve("receipts.dat");
UUID voteId = UUID.randomUUID();
ProcessedVoteCache cache = new ProcessedVoteCache(receipts);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(receipts);
assertTrue(cache.reserve(voteId));
assertTrue(cache.complete(voteId));
assertTrue(cache.releaseCompletedReceipt(voteId));
assertFalse(new ProcessedVoteCache(receipts).reserve(voteId));
assertFalse(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
}
@Test
void unknownReceiptReleaseLeavesRestartSafeTombstone() {
Path receipts = directory.resolve("receipts.dat");
UUID voteId = UUID.randomUUID();
ProcessedVoteCache cache = new ProcessedVoteCache(receipts);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(receipts);
assertFalse(cache.hasDurableReceipt(voteId));
assertTrue(cache.releaseCompletedReceipt(voteId));
assertTrue(cache.hasDurableReceipt(voteId));
assertFalse(new ProcessedVoteCache(receipts).reserve(voteId));
assertFalse(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
}
@Test
void completionHeadroomAllowsPrecedingVotesBeforeRelease() throws Exception {
DurableVoteReceiptStore store = new DurableVoteReceiptStore(directory.resolve("receipts.dat"), 1, 2, 1);
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(directory.resolve("receipts.dat"), 1, 2, 1);
UUID first = UUID.randomUUID();
UUID second = UUID.randomUUID();
UUID third = UUID.randomUUID();
@@ -173,14 +176,14 @@ class ProcessedVoteCacheDurabilityTest {
@Test
void activeReceiptWaitsWhenTombstoneCapacityIsFull() throws Exception {
Path receipts = directory.resolve("receipts.dat");
DurableVoteReceiptStore store = new DurableVoteReceiptStore(receipts, 1, 0, 1);
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(receipts, 1, 0, 1);
UUID active = UUID.randomUUID();
assertTrue(store.complete(active) > 0L);
assertTrue(store.release(UUID.randomUUID()) > 0L);
assertFalse(store.release(active) > 0L);
new DurableVoteReceiptStore(receipts, 1, 0, 1);
new DurableVoteReceiptStoreAccess(receipts, 1, 0, 1);
}
@Test
@@ -189,12 +192,12 @@ class ProcessedVoteCacheDurabilityTest {
UUID voteId = UUID.randomUUID();
Files.writeString(receipts, "VP-VOTE-RECEIPTS-1\nR\t" + voteId + "\t1\n");
assertTrue(new ProcessedVoteCache(receipts).reserve(voteId));
assertTrue(ProcessedVoteCacheAccess.create(receipts).reserve(voteId));
}
@Test
void saturationDoesNotEvictLiveReplayFences() {
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 2);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 2);
UUID first = UUID.randomUUID();
UUID second = UUID.randomUUID();
@@ -208,19 +211,19 @@ class ProcessedVoteCacheDurabilityTest {
@Test
void defaultCapacityIncludesDurableCompletionHeadroom() {
assertTrue(ProcessedVoteCache.DEFAULT_MAX_TRACKED_VOTES
== DurableVoteReceiptStore.MAX_ACTIVE_RECEIPTS + DurableVoteReceiptStore.COMPLETION_HEADROOM);
assertTrue(ProcessedVoteCache.DEFAULT_MAX_TRACKED_VOTES <= DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS);
assertTrue((long) DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS * DurableVoteReceiptStore.MAX_RECORD_BYTES
+ "VP-VOTE-RECEIPTS-1\n".length() <= DurableVoteReceiptStore.MAX_FILE_BYTES);
assertTrue((long) (DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS + 1)
* DurableVoteReceiptStore.MAX_RECORD_BYTES + "VP-VOTE-RECEIPTS-1\n".length()
> DurableVoteReceiptStore.MAX_FILE_BYTES);
assertTrue(ProcessedVoteCacheAccess.DEFAULT_MAX_TRACKED_VOTES
== DurableVoteReceiptStoreAccess.MAX_ACTIVE_RECEIPTS + DurableVoteReceiptStoreAccess.COMPLETION_HEADROOM);
assertTrue(ProcessedVoteCacheAccess.DEFAULT_MAX_TRACKED_VOTES <= DurableVoteReceiptStoreAccess.MAX_TOTAL_RECEIPTS);
assertTrue((long) DurableVoteReceiptStoreAccess.MAX_TOTAL_RECEIPTS * DurableVoteReceiptStoreAccess.MAX_RECORD_BYTES
+ "VP-VOTE-RECEIPTS-1\n".length() <= DurableVoteReceiptStoreAccess.MAX_FILE_BYTES);
assertTrue((long) (DurableVoteReceiptStoreAccess.MAX_TOTAL_RECEIPTS + 1)
* DurableVoteReceiptStoreAccess.MAX_RECORD_BYTES + "VP-VOTE-RECEIPTS-1\n".length()
> DurableVoteReceiptStoreAccess.MAX_FILE_BYTES);
}
@Test
void mixedActiveAndReleaseReceiptsRespectTheJournalByteAlignedLimit() throws Exception {
DurableVoteReceiptStore store = new DurableVoteReceiptStore(directory.resolve("mixed-receipts.dat"), 2, 0, 2, 2);
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(directory.resolve("mixed-receipts.dat"), 2, 0, 2, 2);
UUID released = UUID.randomUUID();
UUID active = UUID.randomUUID();
@@ -244,7 +247,7 @@ class ProcessedVoteCacheDurabilityTest {
+ "R\t" + firstReleased + "\t" + expiresAt + "\n"
+ "R\t" + secondReleased + "\t" + expiresAt + "\n");
DurableVoteReceiptStore store = new DurableVoteReceiptStore(receipts, 2, 0, 2, 2);
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(receipts, 2, 0, 2, 2);
assertTrue(store.contains(firstActive));
assertTrue(store.contains(secondActive));
@@ -255,11 +258,11 @@ class ProcessedVoteCacheDurabilityTest {
@Test
void mixedJournalUsesDurablePerKindCapacityForNewAdmission() throws Exception {
DurableVoteReceiptStore store = new DurableVoteReceiptStore(directory.resolve("mixed-capacity.dat"), 2, 1, 2, 4);
DurableVoteReceiptStoreAccess store = new DurableVoteReceiptStoreAccess(directory.resolve("mixed-capacity.dat"), 2, 1, 2, 4);
assertTrue(store.release(UUID.randomUUID()) > 0L);
assertTrue(store.release(UUID.randomUUID()) > 0L);
assertTrue(store.complete(UUID.randomUUID()) > 0L);
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 2, store);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 2, store);
UUID admitted = UUID.randomUUID();
UUID stale = UUID.randomUUID();
cache.getProcessedVotes().put(stale, 0L);
@@ -273,7 +276,7 @@ class ProcessedVoteCacheDurabilityTest {
@Test
void cancelledValidationReservationFreesCapacity() {
ProcessedVoteCache cache = new ProcessedVoteCache(TimeUnit.MINUTES.toMillis(30), 1);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(TimeUnit.MINUTES.toMillis(30), 1);
UUID invalid = UUID.randomUUID();
assertTrue(cache.reserve(invalid));
@@ -16,7 +16,7 @@ public class ProcessedVoteCacheTest {
@Test
public void duplicateVoteIdIsRejected() {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
UUID voteId = UUID.randomUUID();
assertTrue(cache.reserve(voteId));
assertFalse(cache.reserve(voteId));
@@ -25,7 +25,7 @@ public class ProcessedVoteCacheTest {
@Test
public void nullVoteIdsRemainCompatible() {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
assertTrue(cache.reserve(null));
assertTrue(cache.reserve(null));
assertTrue(cache.getProcessedVotes().isEmpty());
@@ -33,7 +33,7 @@ public class ProcessedVoteCacheTest {
@Test
public void expiredVoteIdCanBeReservedAgain() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache(1);
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create(1);
UUID voteId = UUID.randomUUID();
assertTrue(cache.reserve(voteId));
Thread.sleep(5);
@@ -43,7 +43,7 @@ public class ProcessedVoteCacheTest {
@Test
public void redisDeliveryIsAcceptedOnceAcrossSharedHandlers() {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
String deliveryId = UUID.randomUUID().toString();
assertTrue(cache.reserveRedisDelivery(deliveryId));
assertFalse(cache.reserveRedisDelivery(deliveryId));
@@ -53,7 +53,7 @@ public class ProcessedVoteCacheTest {
@Test
public void legacyRedisHandoffMatchesOldAndBufferedDeliveryCounts() {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
Object previous = new Object();
Object replacement = new Object();
cache.registerRedisSubscriber(previous);
@@ -69,7 +69,7 @@ public class ProcessedVoteCacheTest {
@Test
public void legacyRedisHandoffAccountingIsBounded() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
Object previous = new Object();
Object replacement = new Object();
cache.registerRedisSubscriber(previous);
@@ -87,7 +87,7 @@ public class ProcessedVoteCacheTest {
@Test
public void legacyRedisHandoffAccountingRejectsOversizedSignatures() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
Object previous = new Object();
cache.registerRedisSubscriber(previous);
cache.registerRedisSubscriber(new Object());
@@ -0,0 +1,16 @@
package com.bencodez.votingplugin.tests.backendproxy.global;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.global.*;
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync.*;
import java.util.concurrent.TimeUnit;
/** Typed test-only access to package-private BackendGlobalDataSync seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendGlobalDataSyncAccess {
public static void updateLastOnline(BackendGlobalDataSync target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync", target, "updateLastOnline", new String[]{}, new Object[]{}); }
private BackendGlobalDataSyncAccess() { }
public static void close(BackendGlobalDataSync target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync", target, "close", new String[]{}, new Object[]{}); }
public static void close(BackendGlobalDataSync target, long timeout, TimeUnit unit) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync", target, "close", new String[]{"long", "TimeUnit"}, new Object[]{timeout, unit}); }
}
@@ -1,4 +1,8 @@
package com.bencodez.votingplugin.backendproxy.global;
package com.bencodez.votingplugin.tests.backendproxy.global;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -287,7 +291,7 @@ class BackendGlobalDataSyncTest {
oldSync.checkGlobalData();
org.junit.jupiter.api.Assertions.assertEquals(1, schedulerRuns.get());
oldSync.handoffCompletionSender(replacement);
oldSync.close(1, TimeUnit.MILLISECONDS);
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
replacement.checkGlobalData();
org.junit.jupiter.api.Assertions.assertEquals(1, schedulerRuns.get());
@@ -400,7 +404,7 @@ class BackendGlobalDataSyncTest {
CompletableFuture<Void> execution = CompletableFuture.runAsync(asyncWrite.get());
assertTrue(writeStarted.await(1, TimeUnit.SECONDS));
oldSync.handoffCompletionSender(replacement);
oldSync.close(1, TimeUnit.MILLISECONDS);
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
replacement.checkGlobalData();
verify(replacementHandler).setBoolean("lobby", "ForceUpdate", false);
@@ -551,7 +555,7 @@ class BackendGlobalDataSyncTest {
setField(sync, "globalDataHandler", handler);
setField(sync, "ownsGlobalMysql", false);
CompletableFuture<Void> heartbeat = CompletableFuture.runAsync(sync::updateLastOnline);
CompletableFuture<Void> heartbeat = CompletableFuture.runAsync(() -> BackendGlobalDataSyncAccess.updateLastOnline(sync));
assertTrue(heartbeatStarted.await(1, TimeUnit.SECONDS));
CompletableFuture<Void> close = CompletableFuture.runAsync(sync::close);
Thread.sleep(50L);
@@ -961,7 +965,7 @@ class BackendGlobalDataSyncTest {
assertTrue(oldSync.checkGlobalDataTime(TimeType.DAY, data));
scheduled.get().run();
oldSync.handoffCompletionSender(replacement);
oldSync.close(1, TimeUnit.MILLISECONDS);
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
replacement.checkGlobalData();
verify(timeChecker).forceChanged(TimeType.DAY, false, true, true);
@@ -1083,7 +1087,7 @@ class BackendGlobalDataSyncTest {
week.put(TimeType.WEEK.toString(), new DataValueBoolean(true));
assertTrue(oldSync.checkGlobalDataTime(TimeType.DAY, day));
oldSync.close(1, TimeUnit.MILLISECONDS);
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
assertFalse(replacement.checkGlobalDataTime(TimeType.DAY, day));
assertTrue(replacement.checkGlobalDataTime(TimeType.WEEK, week));
org.junit.jupiter.api.Assertions.assertEquals(2, scheduled.size());
@@ -1124,7 +1128,7 @@ class BackendGlobalDataSyncTest {
week.put(TimeType.WEEK.toString(), new DataValueBoolean(true));
assertTrue(oldSync.checkGlobalDataTime(TimeType.DAY, day));
oldSync.close(1, TimeUnit.MILLISECONDS);
BackendGlobalDataSyncAccess.close(oldSync, 1, TimeUnit.MILLISECONDS);
BackendGlobalDataSync replacement = new BackendGlobalDataSync(plugin, ignored -> { });
setField(replacement, "globalDataHandler", replacementHandler);
@@ -1219,7 +1223,7 @@ class BackendGlobalDataSyncTest {
() -> sync.checkGlobalDataTime(TimeType.DAY, data));
assertTrue(writeStarted.await(1, TimeUnit.SECONDS));
sync.close(50L, TimeUnit.MILLISECONDS);
BackendGlobalDataSyncAccess.close(sync, 50L, TimeUnit.MILLISECONDS);
verify(mysql).close();
releaseWrite.countDown();
@@ -1239,7 +1243,7 @@ class BackendGlobalDataSyncTest {
setField(sync, "globalDataHandler", handler);
setField(sync, "ownsGlobalMysql", false);
sync.close();
BackendGlobalDataSyncAccess.close(sync);
verify(mysql, never()).close();
}
@@ -1254,7 +1258,7 @@ class BackendGlobalDataSyncTest {
setField(sync, "globalDataHandler", handler);
setField(sync, "ownsGlobalMysql", true);
sync.close();
BackendGlobalDataSyncAccess.close(sync);
verify(mysql).close();
}
@@ -1263,7 +1267,7 @@ class BackendGlobalDataSyncTest {
try {
Field field = BackendGlobalDataSync.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
ReflectiveAccess.set(field, target, value);
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
}
@@ -0,0 +1,14 @@
package com.bencodez.votingplugin.tests.backendproxy.messaging;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.messaging.*;
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.*;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
/** Typed test-only access to package-private BackendProxyMessageRouter seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendProxyMessageRouterAccess {
private BackendProxyMessageRouterAccess() { }
public static void handleVoteUpdate(BackendProxyMessageRouter target, JsonEnvelope msg) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter", target, "handleVoteUpdate", new String[]{"JsonEnvelope"}, new Object[]{msg}); }
public static void handleVoteUpdate(BackendProxyMessageRouter target, JsonEnvelope msg, Runnable completion) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter", target, "handleVoteUpdate", new String[]{"JsonEnvelope", "Runnable"}, new Object[]{msg, completion}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.backendproxy.messaging;
package com.bencodez.votingplugin.tests.backendproxy.messaging;
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -114,7 +116,7 @@ class BackendProxyMessageRouterTest {
@Test
void ignoresLastVoteTimeForUnknownServiceSite() {
router.handleVoteUpdate(VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
BackendProxyMessageRouterAccess.handleVoteUpdate(router, VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
"unknown.example\nforged-entry", LAST_VOTE_TIME, ""));
verify(user, times(2)).cache();
@@ -130,7 +132,7 @@ class BackendProxyMessageRouterTest {
VoteSite voteSite = mock(VoteSite.class);
when(voteSiteManager.getVoteSite("known.example", true)).thenReturn(voteSite);
router.handleVoteUpdate(VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
BackendProxyMessageRouterAccess.handleVoteUpdate(router, VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
"known.example", LAST_VOTE_TIME, ""));
verify(user, times(2)).cache();
@@ -787,7 +789,7 @@ class BackendProxyMessageRouterTest {
}).when(coreUserManager).getUserAsync(eq(PLAYER_UUID), any(), any());
AtomicInteger completions = new AtomicInteger();
router.handleVoteUpdate(VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
BackendProxyMessageRouterAccess.handleVoteUpdate(router, VotingPluginWire.voteUpdate(PLAYER_UUID.toString(), 1, 10,
"known.example", LAST_VOTE_TIME, ""), completions::incrementAndGet);
assertEquals(1, completions.get());
@@ -1,4 +1,8 @@
package com.bencodez.votingplugin.backendproxy.messaging;
package com.bencodez.votingplugin.tests.backendproxy.messaging;
import com.bencodez.votingplugin.tests.backendproxy.cache.ProcessedVoteCacheAccess;
import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -92,7 +96,7 @@ class BackendProxyVoteDelayReplayTest {
fixture.assertEffects(1);
fixture.assertOccurrence(0, voteId, RECORDED_TIME - 60_000L);
verify(fixture.site, never()).giveWaitUntilVoteDelayRewards(any(), anyBoolean(), anyBoolean());
assertTrue(new ProcessedVoteCache(fixture.receipts).hasDurableReceipt(voteId));
assertTrue(ProcessedVoteCacheAccess.create(fixture.receipts).hasDurableReceipt(voteId));
});
}
@@ -277,7 +281,7 @@ class BackendProxyVoteDelayReplayTest {
private void restartReceiver() {
router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class),
mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class),
new ProcessedVoteCache(receipts));
ProcessedVoteCacheAccess.create(receipts));
}
private void deliver(JsonEnvelope envelope) {
@@ -0,0 +1,54 @@
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.*;
import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport.*;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler;
import com.bencodez.simpleapi.servercomm.http.HttpBackendTransportConnector;
import com.bencodez.simpleapi.servercomm.http.HttpConnectionCode;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
/** Typed test-only access to package-private HttpBackendProxyTransport seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class HttpBackendProxyTransportAccess {
public static void validate(HttpBackendProxyTransport target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "validate", new String[]{}, new Object[]{}); }
public static void validate(HttpBackendProxyTransport target, long deadlineNanos) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "validate", new String[]{"long"}, new Object[]{deadlineNanos}); }
public static void activateRestoredHandoffDrain(HttpBackendProxyTransport target, int managerMessages) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "activateRestoredHandoffDrain", new String[]{"int"}, new Object[]{managerMessages}); }
public static void appendShutdownHandoffMessages(HttpBackendProxyTransport target, java.util.List<JsonEnvelope> messages) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "appendShutdownHandoffMessages", new String[]{"List"}, new Object[]{messages}); }
public static void acceptHandoffMessages(HttpBackendProxyTransport target, java.util.List<JsonEnvelope> messages) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "acceptHandoffMessages", new String[]{"List"}, new Object[]{messages}); }
public static void beginPreparedHandoff(HttpBackendProxyTransport target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "beginPreparedHandoff", new String[]{}, new Object[]{}); }
public static void flushForReplacement(HttpBackendProxyTransport target, HttpBackendTransportConnector connector, long deadlineNanos) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "flushForReplacement", new String[]{"HttpBackendTransportConnector", "long"}, new Object[]{connector, deadlineNanos}); }
public static java.util.List<JsonEnvelope> drainPreparedMessages(HttpBackendProxyTransport target) { return (java.util.List<JsonEnvelope>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "drainPreparedMessages", new String[]{}, new Object[]{}); }
public static boolean enrollForStartup(HttpBackendProxyTransport target, HttpConnectionCode code, String serverId, Path directory, boolean retryEnrollment, java.util.function.LongPredicate waitForRetry) throws Exception { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "enrollForStartup", new String[]{"HttpConnectionCode", "String", "Path", "boolean", "LongPredicate"}, new Object[]{code, serverId, directory, retryEnrollment, waitForRetry}); }
public static void dispatchAfterPublication(HttpBackendProxyTransport target, GlobalMessageHandler messageHandler, JsonEnvelope envelope) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "dispatchAfterPublication", new String[]{"GlobalMessageHandler", "JsonEnvelope"}, new Object[]{messageHandler, envelope}); }
private HttpBackendProxyTransportAccess() { }
public static HttpBackendProxyTransport create(VotingPluginMain plugin) { return (HttpBackendProxyTransport) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", new String[]{"VotingPluginMain"}, new Object[]{plugin}); }
public static HttpBackendProxyTransport create(VotingPluginMain plugin, TransportEnvelopeEncryption encryption) { return (HttpBackendProxyTransport) ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", new String[]{"VotingPluginMain", "TransportEnvelopeEncryption"}, new Object[]{plugin, encryption}); }
public static HttpBackendProxyTransport recreatePrepared(HttpBackendProxyTransport target) { return (HttpBackendProxyTransport) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "recreatePrepared", new String[]{}, new Object[]{}); }
public static boolean isClosedForReplacement(HttpBackendProxyTransport target) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "isClosedForReplacement", new String[]{}, new Object[]{}); }
public static int preparedMessageCount(HttpBackendProxyTransport target) { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "preparedMessageCount", new String[]{}, new Object[]{}); }
public static java.util.List<JsonEnvelope> preparedMessagesSnapshot(HttpBackendProxyTransport target) { return (java.util.List<JsonEnvelope>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "preparedMessagesSnapshot", new String[]{}, new Object[]{}); }
public static void finishInitialization(boolean installed, HttpBackendTransportConnector replacement, boolean acquired, Semaphore owner, CountDownLatch completion) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "finishInitialization", new String[]{"boolean", "HttpBackendTransportConnector", "boolean", "Semaphore", "CountDownLatch"}, new Object[]{installed, replacement, acquired, owner, completion}); }
public static void transferStartupQueue(ArrayDeque<JsonEnvelope> queue, HttpBackendTransportConnector connector) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "transferStartupQueue", new String[]{"ArrayDeque", "HttpBackendTransportConnector"}, new Object[]{queue, connector}); }
public static void restoreUnenrolledCredentialState(Path directory, String serverId, String configuredCode) throws Exception { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "restoreUnenrolledCredentialState", new String[]{"Path", "String", "String"}, new Object[]{directory, serverId, configuredCode}); }
public static void dispatchIncoming(HttpBackendProxyTransport target, GlobalMessageHandler messageHandler, JsonEnvelope envelope, long deadlineNanos) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "dispatchIncoming", new String[]{"GlobalMessageHandler", "JsonEnvelope", "long"}, new Object[]{messageHandler, envelope, deadlineNanos}); }
public static java.util.List<JsonEnvelope> handoffMessagesSnapshot(HttpBackendProxyTransport target) { return (java.util.List<JsonEnvelope>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", target, "handoffMessagesSnapshot", new String[]{}, new Object[]{}); }
public static HttpConnectionCode enrollmentCode(Path directory, String serverId, String configuredCode) { return (HttpConnectionCode) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "enrollmentCode", new String[]{"Path", "String", "String"}, new Object[]{directory, serverId, configuredCode}); }
public static boolean flushHandoffForShutdown(HttpBackendTransportConnector active, java.util.List<JsonEnvelope> finalHandoff, long deadlineNanos) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport", null, "flushHandoffForShutdown", new String[]{"HttpBackendTransportConnector", "List", "long"}, new Object[]{active, finalHandoff, deadlineNanos}); }
}
@@ -1,4 +1,10 @@
package com.bencodez.votingplugin.backendproxy.transport;
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransport;
import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransportManager;
import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -64,7 +70,7 @@ class HttpBackendProxyTransportTest {
}).when(scheduler).runTask(org.mockito.ArgumentMatchers.eq(plugin), org.mockito.ArgumentMatchers.any(Runnable.class));
JsonEnvelope envelope = JsonEnvelope.builder("test").put("message", "payload").build();
new HttpBackendProxyTransport(plugin).dispatchIncoming(handler, envelope,
HttpBackendProxyTransportAccess.dispatchIncoming(new HttpBackendProxyTransport(plugin), handler, envelope,
System.nanoTime() + TimeUnit.SECONDS.toNanos(1));
assertTrue(callbackFinished.await(1, TimeUnit.SECONDS));
@@ -73,7 +79,7 @@ class HttpBackendProxyTransportTest {
@Test
void publicationActivatesTheSimpleApiInboundBarrier() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
setField(transport, "connector", connector);
@@ -84,10 +90,10 @@ class HttpBackendProxyTransportTest {
@Test
void closedPublicationGateRejectsDeliveryForReplay() {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
transport.close();
assertThrows(IllegalStateException.class, () -> transport.dispatchAfterPublication(
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.dispatchAfterPublication(transport,
mock(GlobalMessageHandler.class), JsonEnvelope.builder("test").build()));
}
@@ -98,7 +104,7 @@ class HttpBackendProxyTransportTest {
when(connector.send(pending)).thenReturn(false, true);
when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
assertTrue(HttpBackendProxyTransport.flushHandoffForShutdown(connector, List.of(pending),
assertTrue(HttpBackendProxyTransportAccess.flushHandoffForShutdown(connector, List.of(pending),
System.nanoTime() + TimeUnit.SECONDS.toNanos(1)));
org.mockito.InOrder order = org.mockito.Mockito.inOrder(connector);
@@ -109,7 +115,7 @@ class HttpBackendProxyTransportTest {
@Test
@SuppressWarnings("unchecked")
void unpublishedHandoffMessagesAreDiscardedOnRollback() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
setField(transport, "connector", connector);
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue"))
@@ -130,7 +136,7 @@ class HttpBackendProxyTransportTest {
when(connector.send(pending)).thenReturn(false);
assertThrows(IllegalStateException.class,
() -> HttpBackendProxyTransport.transferStartupQueue(queue, connector));
() -> HttpBackendProxyTransportAccess.transferStartupQueue(queue, connector));
assertEquals(java.util.List.of(pending), new java.util.ArrayList<>(queue));
}
@@ -142,7 +148,7 @@ class HttpBackendProxyTransportTest {
CountDownLatch completion = new CountDownLatch(1);
assertThrows(IllegalStateException.class,
() -> HttpBackendProxyTransport.finishInitialization(false, connector, true, owner, completion));
() -> HttpBackendProxyTransportAccess.finishInitialization(false, connector, true, owner, completion));
verify(connector).close();
assertEquals(1, owner.availablePermits());
@@ -152,7 +158,7 @@ class HttpBackendProxyTransportTest {
@Test
@SuppressWarnings("unchecked")
void orderlyShutdownFlushesRecoveryQueueBeforeHandoffQueue() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
JsonEnvelope recovery = JsonEnvelope.builder("recovery").build();
JsonEnvelope handoff = JsonEnvelope.builder("handoff").build();
@@ -181,8 +187,8 @@ class HttpBackendProxyTransportTest {
setField(manager, "transport", failed);
org.mockito.Mockito.doThrow(new IllegalStateException("worker still stopping"))
.when(failed).prepareForReplacement();
when(failed.isClosedForReplacement()).thenReturn(true);
when(failed.recreatePrepared()).thenReturn(restored);
when(HttpBackendProxyTransportAccess.isClosedForReplacement(failed)).thenReturn(true);
when(HttpBackendProxyTransportAccess.recreatePrepared(failed)).thenReturn(restored);
assertThrows(IllegalStateException.class, manager::prepareForReplacement);
manager.send(queued);
@@ -203,7 +209,7 @@ class HttpBackendProxyTransportTest {
manager.send(queued);
verify(active).send(queued);
verify(active, org.mockito.Mockito.never()).recreatePrepared();
HttpBackendProxyTransportAccess.recreatePrepared(verify(active, org.mockito.Mockito.never()));
}
@Test
@@ -267,7 +273,7 @@ class HttpBackendProxyTransportTest {
});
assertThrows(IllegalStateException.class,
() -> new HttpBackendProxyTransport(plugin).dispatchIncoming(handler,
() -> HttpBackendProxyTransportAccess.dispatchIncoming(new HttpBackendProxyTransport(plugin), handler,
JsonEnvelope.builder("test").build(), System.nanoTime()));
assertTrue(scheduled.get() != null);
scheduled.get().run();
@@ -294,7 +300,7 @@ class HttpBackendProxyTransportTest {
AtomicReference<Throwable> dispatchFailure = new AtomicReference<>();
Thread dispatch = new Thread(() -> {
try {
new HttpBackendProxyTransport(plugin).dispatchIncoming(handler,
HttpBackendProxyTransportAccess.dispatchIncoming(new HttpBackendProxyTransport(plugin), handler,
JsonEnvelope.builder("test").build(), System.nanoTime() + TimeUnit.SECONDS.toNanos(5));
} catch (Throwable thrown) {
dispatchFailure.set(thrown);
@@ -317,28 +323,28 @@ class HttpBackendProxyTransportTest {
when(plugin.getDataFolder()).thenReturn(directory.toFile());
when(plugin.getBungeeSettings()).thenReturn(settings);
when(settings.getServer()).thenReturn("lobby-1");
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
when(settings.getHttpConnectionCode()).thenReturn("malformed");
assertThrows(IllegalStateException.class, transport::validate);
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.validate(transport));
when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().minusSeconds(1)).encode());
assertThrows(IllegalStateException.class, transport::validate);
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.validate(transport));
when(settings.getHttpConnectionCode()).thenReturn(code("survival", Instant.now().plusSeconds(60)).encode());
assertThrows(IllegalStateException.class, transport::validate);
assertThrows(IllegalStateException.class, () -> HttpBackendProxyTransportAccess.validate(transport));
assertThrows(IllegalStateException.class, () -> transport.start(mock(GlobalMessageHandler.class)),
"invalid configuration must fail before the enrollment worker starts");
when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().plusSeconds(60)).encode());
assertDoesNotThrow(() -> transport.validate());
assertDoesNotThrow(() -> HttpBackendProxyTransportAccess.validate(transport));
}
@Test
void ordinaryStartupRetriesTransientInitialEnrollmentWithBoundedBackoff() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
HttpConnectionCode enrollment = code("lobby-1", Instant.now().plusSeconds(60));
List<Long> delays = new ArrayList<>();
java.util.concurrent.atomic.AtomicInteger attempts = new java.util.concurrent.atomic.AtomicInteger();
@@ -349,7 +355,7 @@ class HttpBackendProxyTransportTest {
if (attempts.getAndIncrement() == 0) throw new IOException("proxy unavailable");
return null;
});
assertTrue(transport.enrollForStartup(enrollment, "lobby-1", directory, true,
assertTrue(HttpBackendProxyTransportAccess.enrollForStartup(transport, enrollment, "lobby-1", directory, true,
delay -> { delays.add(delay); return true; }));
}
@@ -359,11 +365,11 @@ class HttpBackendProxyTransportTest {
@Test
void replacementCancelsRetryingInitialEnrollmentWithoutCredential() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
Path credentials = directory.resolve("http");
java.lang.reflect.Field directoryField = HttpBackendProxyTransport.class.getDeclaredField("configuredDirectory");
directoryField.setAccessible(true);
directoryField.set(transport, credentials);
ReflectiveAccess.set(directoryField, transport, credentials);
java.lang.reflect.Field ownersField = HttpBackendProxyTransport.class.getDeclaredField("DIRECTORY_OWNERS");
ownersField.setAccessible(true);
@SuppressWarnings("unchecked")
@@ -380,7 +386,7 @@ class HttpBackendProxyTransportTest {
});
java.lang.reflect.Field workerField = HttpBackendProxyTransport.class.getDeclaredField("worker");
workerField.setAccessible(true);
workerField.set(transport, retryingEnrollment);
ReflectiveAccess.set(workerField, transport, retryingEnrollment);
retryingEnrollment.start();
assertTrue(workerStarted.await(1, TimeUnit.SECONDS));
JsonEnvelope queued = JsonEnvelope.builder("queued-before-replacement").build();
@@ -392,7 +398,7 @@ class HttpBackendProxyTransportTest {
HttpBackendProxyTransport.class.getDeclaredField("restoreUnenrolledState");
restoreAbsent.setAccessible(true);
assertTrue(restoreAbsent.getBoolean(transport), "rollback must remember that no credential existed");
assertEquals(List.of(queued), transport.drainPreparedMessages(),
assertEquals(List.of(queued), HttpBackendProxyTransportAccess.drainPreparedMessages(transport),
"messages accepted before replacement must remain available for handoff");
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
@@ -404,7 +410,7 @@ class HttpBackendProxyTransportTest {
@Test
void replacementSnapshotsInitialEnrollmentPublishedWhileCancellationCompletes() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
Path credentials = directory.resolve("http");
HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("original-proxy"),
"proxy.example.test");
@@ -451,7 +457,7 @@ class HttpBackendProxyTransportTest {
assertTrue(HttpClientCredentialStore.hasEnrolledProfile(credentials));
HttpConnectionCode original = code("lobby-1", Instant.now().plusSeconds(60));
HttpBackendProxyTransport.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
HttpBackendProxyTransportAccess.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
assertFalse(HttpClientCredentialStore.hasEnrolledProfile(credentials),
"a failed staged first enrollment must not remain active during rollback");
@@ -466,7 +472,7 @@ class HttpBackendProxyTransportTest {
identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "F".repeat(43));
HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1"));
HttpBackendProxyTransport.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
HttpBackendProxyTransportAccess.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode());
assertTrue(HttpClientCredentialStore.hasEnrolledProfile(credentials),
"a credential issued for the consumed original code must remain recoverable");
@@ -476,13 +482,13 @@ class HttpBackendProxyTransportTest {
void stagedEnrollmentRemainsFailFast() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
HttpConnectionCode enrollment = code("lobby-1", Instant.now().plusSeconds(60));
try (MockedStatic<HttpBackendTransportConnector> connector = org.mockito.Mockito.mockStatic(HttpBackendTransportConnector.class)) {
connector.when(() -> HttpBackendTransportConnector.enroll(enrollment, "lobby-1", directory))
.thenThrow(new IOException("proxy unavailable"));
assertThrows(IOException.class,
() -> transport.enrollForStartup(enrollment, "lobby-1", directory, false,
() -> HttpBackendProxyTransportAccess.enrollForStartup(transport, enrollment, "lobby-1", directory, false,
delay -> { throw new AssertionError("staged enrollment must not retry"); }));
}
}
@@ -494,32 +500,32 @@ class HttpBackendProxyTransportTest {
HttpConnectionCode original = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"),
identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().minusSeconds(1), "A".repeat(43));
HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1"));
assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", ""));
assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", original.encode()),
assertNull(HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", ""));
assertNull(HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", original.encode()),
"the already-consumed code must not be retried, even after it expires");
HttpConnectionCode replacement = new HttpConnectionCode("lobby-1", original.endpoint(), original.serverCertificatePin(),
original.caCertificatePin(), Instant.now().plusSeconds(60), "B".repeat(43));
assertEquals(replacement.encode(), HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", replacement.encode()).encode());
assertEquals(replacement.encode(), HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", replacement.encode()).encode());
assertThrows(IllegalStateException.class,
() -> HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", "malformed"));
() -> HttpBackendProxyTransportAccess.enrollmentCode(credentials, "lobby-1", "malformed"));
}
@Test
void failedReplacementFlushResumesTheExistingConnector() {
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(false);
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
assertThrows(IllegalStateException.class,
() -> transport.flushForReplacement(connector, System.nanoTime()));
() -> HttpBackendProxyTransportAccess.flushForReplacement(transport, connector, System.nanoTime()));
verify(connector).start();
}
@Test
void closeNeverWaitsForSetupOnTheCallingThread() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1);
Thread blocked = new Thread(() -> {
started.countDown();
@@ -530,7 +536,7 @@ class HttpBackendProxyTransportTest {
assertTrue(started.await(1, TimeUnit.SECONDS));
java.lang.reflect.Field worker = HttpBackendProxyTransport.class.getDeclaredField("worker");
worker.setAccessible(true);
worker.set(transport, blocked);
ReflectiveAccess.set(worker, transport, blocked);
long startedAt = System.nanoTime();
transport.close();
@@ -541,7 +547,7 @@ class HttpBackendProxyTransportTest {
@Test
void prepareForReplacementSnapshotsThePersistedCredentialGeneration() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
Path credentials = directory.resolve("http");
HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("replacement-proxy"), "proxy.example.test");
HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"),
@@ -551,12 +557,12 @@ class HttpBackendProxyTransportTest {
HttpClientCredentialStore.snapshotActiveGeneration(credentials);
java.lang.reflect.Field directoryField = HttpBackendProxyTransport.class.getDeclaredField("configuredDirectory");
directoryField.setAccessible(true);
directoryField.set(transport, credentials);
ReflectiveAccess.set(directoryField, transport, credentials);
java.lang.reflect.Field connectorField = HttpBackendProxyTransport.class.getDeclaredField("connector");
connectorField.setAccessible(true);
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true);
connectorField.set(transport, connector);
ReflectiveAccess.set(connectorField, transport, connector);
assertDoesNotThrow(transport::prepareForReplacement);
java.lang.reflect.Field generationField =
@@ -585,12 +591,12 @@ class HttpBackendProxyTransportTest {
var owners = (java.util.concurrent.ConcurrentHashMap<Path, java.util.concurrent.Semaphore>) ownersField.get(null);
java.util.concurrent.Semaphore predecessor = new java.util.concurrent.Semaphore(0);
owners.put(credentials.toAbsolutePath().normalize(), predecessor);
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
transport.start(mock(GlobalMessageHandler.class));
java.util.concurrent.atomic.AtomicReference<Throwable> failure = new java.util.concurrent.atomic.AtomicReference<>();
CountDownLatch finished = new CountDownLatch(1);
Thread validation = new Thread(() -> {
try { transport.validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); }
try { HttpBackendProxyTransportAccess.validate(transport, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); }
catch (Throwable thrown) { failure.set(thrown); }
finally { finished.countDown(); }
});
@@ -611,32 +617,32 @@ class HttpBackendProxyTransportTest {
void preparedHandoffMessagesStayAheadOfNewlyPublishedSendsWhenConnectorIsFull() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
when(connector.send(org.mockito.ArgumentMatchers.any(JsonEnvelope.class))).thenReturn(false);
setField(transport, "connector", connector);
JsonEnvelope acceptedBeforePublication = JsonEnvelope.builder("old").build();
JsonEnvelope sentAfterPublication = JsonEnvelope.builder("new").build();
transport.beginPreparedHandoff();
HttpBackendProxyTransportAccess.beginPreparedHandoff(transport);
transport.send(sentAfterPublication);
transport.acceptHandoffMessages(List.of(acceptedBeforePublication));
HttpBackendProxyTransportAccess.acceptHandoffMessages(transport, List.of(acceptedBeforePublication));
assertEquals(List.of(acceptedBeforePublication, sentAfterPublication), transport.handoffMessagesSnapshot());
assertEquals(List.of(acceptedBeforePublication, sentAfterPublication), HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport));
transport.close();
}
@Test
@SuppressWarnings("unchecked")
void shutdownManagerRemainderStaysBehindAlreadyTransferredPrefix() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
JsonEnvelope olderPrefix = JsonEnvelope.builder("older-prefix").build();
JsonEnvelope newerRemainder = JsonEnvelope.builder("newer-remainder").build();
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue")).addLast(olderPrefix);
transport.appendShutdownHandoffMessages(List.of(newerRemainder));
HttpBackendProxyTransportAccess.appendShutdownHandoffMessages(transport, List.of(newerRemainder));
assertEquals(List.of(olderPrefix, newerRemainder), transport.handoffMessagesSnapshot());
assertEquals(List.of(olderPrefix, newerRemainder), HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport));
transport.close();
}
@@ -647,8 +653,8 @@ class HttpBackendProxyTransportTest {
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
setField(previous, "preparedTransport", oldTransport);
setField(replacement, "transport", newTransport);
java.util.ArrayDeque<JsonEnvelope> oldQueue =
@@ -677,12 +683,12 @@ class HttpBackendProxyTransportTest {
assertDoesNotThrow(() -> previous.completePreparedTransportHandoff(replacement));
replacement.awaitAsyncHandoff(System.nanoTime() + TimeUnit.SECONDS.toNanos(2));
List<JsonEnvelope> queued = newTransport.handoffMessagesSnapshot();
List<JsonEnvelope> queued = HttpBackendProxyTransportAccess.handoffMessagesSnapshot(newTransport);
assertEquals(4096, queued.size());
assertEquals(oldMessages, queued.subList(0, 2048));
assertEquals(preparedMessages, queued.subList(2048, 3072));
assertEquals(replacementMessages.subList(0, 1024), queued.subList(3072, 4096));
assertEquals(0, oldTransport.preparedMessageCount());
assertEquals(0, HttpBackendProxyTransportAccess.preparedMessageCount(oldTransport));
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(previous, "preparedSends")).isEmpty());
newTransport.close();
}
@@ -693,8 +699,8 @@ class HttpBackendProxyTransportTest {
VotingPluginMain plugin = mock(VotingPluginMain.class);
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
setField(previous, "preparedTransport", oldTransport);
setField(replacement, "transport", newTransport);
java.util.ArrayDeque<JsonEnvelope> oldQueue =
@@ -718,8 +724,8 @@ class HttpBackendProxyTransportTest {
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
setField(previous, "preparedTransport", oldTransport);
setField(replacement, "transport", newTransport);
java.util.ArrayDeque<JsonEnvelope> oldQueue =
@@ -739,7 +745,7 @@ class HttpBackendProxyTransportTest {
assertEquals(3072, oldQueue.size());
assertEquals(1024, ((java.util.ArrayDeque<?>) field(previous, "preparedSends")).size());
assertEquals(2, ((java.util.ArrayDeque<?>) field(replacement, "preparedSends")).size());
assertTrue(newTransport.handoffMessagesSnapshot().isEmpty());
assertTrue(HttpBackendProxyTransportAccess.handoffMessagesSnapshot(newTransport).isEmpty());
}
@Test
@@ -748,7 +754,7 @@ class HttpBackendProxyTransportTest {
VotingPluginMain plugin = mock(VotingPluginMain.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
setField(manager, "transport", transport);
setField(transport, "managerHandoffActive", true);
java.util.ArrayDeque<JsonEnvelope> httpQueue =
@@ -772,7 +778,7 @@ class HttpBackendProxyTransportTest {
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendProxyTransport newTransport = mock(HttpBackendProxyTransport.class);
JsonEnvelope envelope = JsonEnvelope.builder("accepted-once").build();
CountDownLatch sending = new CountDownLatch(1);
@@ -801,8 +807,7 @@ class HttpBackendProxyTransportTest {
closing.join(TimeUnit.SECONDS.toMillis(2));
assertFalse(closing.isAlive());
verify(newTransport).send(envelope);
verify(newTransport, org.mockito.Mockito.never())
.acceptHandoffMessages(org.mockito.ArgumentMatchers.anyList());
HttpBackendProxyTransportAccess.acceptHandoffMessages(verify(newTransport, org.mockito.Mockito.never()), org.mockito.ArgumentMatchers.anyList());
}
@Test
@@ -812,8 +817,8 @@ class HttpBackendProxyTransportTest {
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
JsonEnvelope source = JsonEnvelope.builder("source").build();
JsonEnvelope adapted = JsonEnvelope.builder("adapted").build();
@@ -874,7 +879,7 @@ class HttpBackendProxyTransportTest {
VotingPluginMain plugin = mock(VotingPluginMain.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class);
JsonEnvelope envelope = JsonEnvelope.builder("shutdown").build();
CountDownLatch sending = new CountDownLatch(1);
@@ -941,8 +946,8 @@ class HttpBackendProxyTransportTest {
VotingPluginMain plugin = mock(VotingPluginMain.class);
BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin);
BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport oldTransport = HttpBackendProxyTransportAccess.create(plugin);
HttpBackendProxyTransport newTransport = HttpBackendProxyTransportAccess.create(plugin);
JsonEnvelope old = JsonEnvelope.builder("old").build();
JsonEnvelope prepared = JsonEnvelope.builder("prepared").build();
setField(previous, "preparedTransport", oldTransport);
@@ -956,7 +961,7 @@ class HttpBackendProxyTransportTest {
assertThrows(IllegalStateException.class, () -> previous.completePreparedTransportHandoff(replacement));
assertEquals(List.of(old), oldTransport.preparedMessagesSnapshot());
assertEquals(List.of(old), HttpBackendProxyTransportAccess.preparedMessagesSnapshot(oldTransport));
java.util.ArrayDeque<?> retained = (java.util.ArrayDeque<?>) field(previous, "preparedSends");
assertEquals(1, retained.size());
assertSame(prepared, field(retained.peekFirst(), "source"));
@@ -966,13 +971,13 @@ class HttpBackendProxyTransportTest {
@Test
@SuppressWarnings("unchecked")
void preparedDrainPreservesStartupAndExistingHandoffMessagesInFifoOrder() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
JsonEnvelope startup = JsonEnvelope.builder("startup").build();
JsonEnvelope handoff = JsonEnvelope.builder("handoff").build();
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "startupQueue")).add(startup);
((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue")).add(handoff);
assertEquals(List.of(startup, handoff), transport.drainPreparedMessages());
assertEquals(List.of(startup, handoff), HttpBackendProxyTransportAccess.drainPreparedMessages(transport));
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(transport, "startupQueue")).isEmpty());
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue")).isEmpty());
}
@@ -982,7 +987,7 @@ class HttpBackendProxyTransportTest {
void rollbackRecreateKeepsAnOversizedCombinedQueueInTheDrainedHandoffLane() throws Exception {
VotingPluginMain plugin = mock(VotingPluginMain.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(plugin);
Path credentials = directory.resolve("http");
setField(transport, "configuredDirectory", credentials);
setField(transport, "configuredServerId", "lobby-1");
@@ -1009,7 +1014,7 @@ class HttpBackendProxyTransportTest {
HttpBackendProxyTransport restored = null;
Thread setup = null;
try {
restored = transport.recreatePrepared();
restored = HttpBackendProxyTransportAccess.recreatePrepared(transport);
assertTrue(((java.util.ArrayDeque<JsonEnvelope>) field(restored, "startupQueue")).isEmpty(),
"rollback must not synchronously overfill the connector's 1024-entry queue");
assertEquals(queued,
@@ -1031,7 +1036,7 @@ class HttpBackendProxyTransportTest {
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin);
HttpBackendProxyTransport previous = mock(HttpBackendProxyTransport.class);
HttpBackendProxyTransport restored = new HttpBackendProxyTransport(plugin);
HttpBackendProxyTransport restored = HttpBackendProxyTransportAccess.create(plugin);
java.util.ArrayDeque<JsonEnvelope> restoredQueue =
(java.util.ArrayDeque<JsonEnvelope>) field(restored, "handoffQueue");
for (int index = 0; index < 4096; index++)
@@ -1039,29 +1044,29 @@ class HttpBackendProxyTransportTest {
JsonEnvelope first = JsonEnvelope.builder("fenced-1").build();
JsonEnvelope second = JsonEnvelope.builder("fenced-2").build();
setField(manager, "preparedTransport", previous);
when(previous.recreatePrepared()).thenReturn(restored);
when(HttpBackendProxyTransportAccess.recreatePrepared(previous)).thenReturn(restored);
manager.send(first);
manager.send(second);
manager.restorePreparedTransport();
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (restored.handoffMessagesSnapshot().size() != 4098 && System.nanoTime() < deadline)
while (HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).size() != 4098 && System.nanoTime() < deadline)
Thread.onSpinWait();
assertEquals(4098, restored.handoffMessagesSnapshot().size());
assertSame(first, restored.handoffMessagesSnapshot().get(4096));
assertSame(second, restored.handoffMessagesSnapshot().get(4097));
assertEquals(4098, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).size());
assertSame(first, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).get(4096));
assertSame(second, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).get(4097));
assertTrue(((java.util.ArrayDeque<?>) field(manager, "asyncHandoffSends")).isEmpty());
manager.send(JsonEnvelope.builder("newer-rejected").build());
assertEquals(4098, restored.handoffMessagesSnapshot().size(),
assertEquals(4098, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(restored).size(),
"normal sends must not expand the one-time rollback recovery allowance");
}
@Test
@SuppressWarnings("unchecked")
void shutdownCanAppendTheReservedRollbackRemainder() throws Exception {
HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class));
HttpBackendProxyTransport transport = HttpBackendProxyTransportAccess.create(mock(VotingPluginMain.class));
java.util.ArrayDeque<JsonEnvelope> handoff =
(java.util.ArrayDeque<JsonEnvelope>) field(transport, "handoffQueue");
for (int index = 0; index < 4096; index++)
@@ -1069,12 +1074,12 @@ class HttpBackendProxyTransportTest {
JsonEnvelope first = JsonEnvelope.builder("fenced-1").build();
JsonEnvelope second = JsonEnvelope.builder("fenced-2").build();
transport.activateRestoredHandoffDrain(2);
transport.appendShutdownHandoffMessages(List.of(first, second));
HttpBackendProxyTransportAccess.activateRestoredHandoffDrain(transport, 2);
HttpBackendProxyTransportAccess.appendShutdownHandoffMessages(transport, List.of(first, second));
assertEquals(4098, transport.handoffMessagesSnapshot().size());
assertSame(first, transport.handoffMessagesSnapshot().get(4096));
assertSame(second, transport.handoffMessagesSnapshot().get(4097));
assertEquals(4098, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport).size());
assertSame(first, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport).get(4096));
assertSame(second, HttpBackendProxyTransportAccess.handoffMessagesSnapshot(transport).get(4097));
}
private static HttpConnectionCode code(String serverId, Instant expiry) {
@@ -1085,7 +1090,7 @@ class HttpBackendProxyTransportTest {
private static void setField(Object target, String name, Object value) throws Exception {
java.lang.reflect.Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
ReflectiveAccess.set(field, target, value);
}
private static Object field(Object target, String name) throws Exception {
@@ -0,0 +1,16 @@
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.*;
import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport.*;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
/** Typed test-only access to package-private MqttBackendProxyTransport seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class MqttBackendProxyTransportAccess {
private MqttBackendProxyTransportAccess() { }
public static void updateSecurity(MqttBackendProxyTransport target, SharedTransportEnvelopeAuthenticator replacementAuthenticator, TransportEnvelopeEncryption replacementEncryption) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport", target, "updateSecurity", new String[]{"SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption"}, new Object[]{replacementAuthenticator, replacementEncryption}); }
public static void acceptAuthenticatedEnvelope(MqttBackendProxyTransport target, JsonEnvelope envelope, String topic) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport", target, "acceptAuthenticatedEnvelope", new String[]{"JsonEnvelope", "String"}, new Object[]{envelope, topic}); }
}
@@ -1,4 +1,8 @@
package com.bencodez.votingplugin.backendproxy.transport;
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -34,12 +38,12 @@ class MqttBackendProxyTransportTest {
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
setField(transport, "messageHandler", messages);
setField(transport, "authenticator", authenticator);
transport.updateSecurity(authenticator, encryption);
MqttBackendProxyTransportAccess.updateSecurity(transport, authenticator, encryption);
JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex").build();
JsonEnvelope signed = authenticator.sign(encryption.encrypt(vote), Domain.MQTT_PROXY_BACKEND, "proxy-a",
"votingplugin/servers/backend-a");
transport.acceptAuthenticatedEnvelope(signed, "votingplugin/servers/backend-a");
MqttBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "votingplugin/servers/backend-a");
verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received ->
VotingPluginWire.SUB_VOTE.equals(received.getSubChannel())
@@ -55,9 +59,9 @@ class MqttBackendProxyTransportTest {
setField(transport, "authenticator", authenticator);
JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex").build();
transport.acceptAuthenticatedEnvelope(authenticator.sign(vote, Domain.MQTT_PROXY_BACKEND, "proxy-a", "votingplugin/servers/backend-a"),
MqttBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, authenticator.sign(vote, Domain.MQTT_PROXY_BACKEND, "proxy-a", "votingplugin/servers/backend-a"),
"votingplugin/servers/backend-a");
transport.acceptAuthenticatedEnvelope(vote, "votingplugin/servers/backend-a");
MqttBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, vote, "votingplugin/servers/backend-a");
verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received ->
VotingPluginWire.SUB_VOTE.equals(received.getSubChannel())
@@ -76,9 +80,9 @@ class MqttBackendProxyTransportTest {
JsonEnvelope signed = authenticator.sign(vote, Domain.MQTT_PROXY_BACKEND, "proxy-a",
"votingplugin/servers/backend-a");
transport.acceptAuthenticatedEnvelope(signed, "votingplugin/servers/backend-b");
MqttBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "votingplugin/servers/backend-b");
org.mockito.Mockito.verifyNoInteractions(messages);
transport.acceptAuthenticatedEnvelope(signed, "votingplugin/servers/backend-a");
MqttBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "votingplugin/servers/backend-a");
verify(messages).onMessage(org.mockito.ArgumentMatchers.any());
}
@@ -92,6 +96,6 @@ class MqttBackendProxyTransportTest {
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
ReflectiveAccess.set(field, target, value);
}
}
@@ -0,0 +1,26 @@
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.*;
import com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport.*;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
import redis.clients.jedis.DefaultJedisClientConfig;
/** Typed test-only access to package-private RedisBackendProxyTransport seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class RedisBackendProxyTransportAccess {
public static final int MAX_LEGACY_HANDOFF_DELIVERIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", "MAX_LEGACY_HANDOFF_DELIVERIES");
public static final int MAX_IDENTIFIED_HANDOFF_DELIVERIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", "MAX_IDENTIFIED_HANDOFF_DELIVERIES");
public static final int MAX_REPLAY_HANDOFF_DELIVERIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", "MAX_REPLAY_HANDOFF_DELIVERIES");
public static final int REPLAY_BATCH_SIZE = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", "REPLAY_BATCH_SIZE");
private RedisBackendProxyTransportAccess() { }
public static void updateSecurity(RedisBackendProxyTransport target, SharedTransportEnvelopeAuthenticator replacementAuthenticator, TransportEnvelopeEncryption replacementEncryption) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", target, "updateSecurity", new String[]{"SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption"}, new Object[]{replacementAuthenticator, replacementEncryption}); }
public static void acceptAuthenticatedEnvelope(RedisBackendProxyTransport target, JsonEnvelope envelope, String channel) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", target, "acceptAuthenticatedEnvelope", new String[]{"JsonEnvelope", "String"}, new Object[]{envelope, channel}); }
public static void dispatchReceivedSubscriberEnvelope(RedisBackendProxyTransport target, JsonEnvelope envelope, String deliveryId) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", target, "dispatchReceivedSubscriberEnvelope", new String[]{"JsonEnvelope", "String"}, new Object[]{envelope, deliveryId}); }
public static void dispatchIdentified(RedisBackendProxyTransport target, JsonEnvelope envelope, String deliveryId) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", target, "dispatchIdentified", new String[]{"JsonEnvelope", "String"}, new Object[]{envelope, deliveryId}); }
public static void dispatchLegacy(RedisBackendProxyTransport target, JsonEnvelope envelope) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", target, "dispatchLegacy", new String[]{"JsonEnvelope"}, new Object[]{envelope}); }
public static void fenceAfterHandoff(RedisBackendProxyTransport target) { ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", target, "fenceAfterHandoff", new String[]{}, new Object[]{}); }
public static DefaultJedisClientConfig buildValidationClientConfig(int database, String username, String password, boolean ssl) { return (DefaultJedisClientConfig) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport", null, "buildValidationClientConfig", new String[]{"int", "String", "String", "boolean"}, new Object[]{database, username, password, ssl}); }
}
@@ -1,4 +1,10 @@
package com.bencodez.votingplugin.backendproxy.transport;
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.backendproxy.cache.ProcessedVoteCacheAccess;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransportManager;
import com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -50,17 +56,17 @@ class RedisBackendProxyTransportTest {
SharedTransportEnvelopeAuthenticator authenticator = authenticator();
TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load(
temporaryDirectory.resolve("secretkey.key"), TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true);
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
setField(transport, "messageHandler", messages);
setField(transport, "authenticator", authenticator);
transport.updateSecurity(authenticator, encryption);
RedisBackendProxyTransportAccess.updateSecurity(transport, authenticator, encryption);
JsonEnvelope vote = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).put("player", "Alex").build();
JsonEnvelope encrypted = VotingPluginWire.withRedisDeliveryId(encryption.encrypt(vote));
JsonEnvelope signed = authenticator.sign(encrypted, Domain.REDIS_PROXY_BACKEND, "proxy-a",
"VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "VotingPlugin_backend-a");
verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received ->
VotingPluginWire.SUB_VOTE.equals(received.getSubChannel())
@@ -70,7 +76,7 @@ class RedisBackendProxyTransportTest {
@Test
void authenticatedVoteIsAcceptedAndUnsignedPresenceIsRejectedBeforeDispatch() throws Exception {
SharedTransportEnvelopeAuthenticator authenticator = authenticator();
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
setField(transport, "messageHandler", messages);
setField(transport, "authenticator", authenticator);
@@ -79,9 +85,9 @@ class RedisBackendProxyTransportTest {
JsonEnvelope signedVote = authenticator.sign(VotingPluginWire.withRedisDeliveryId(vote),
Domain.REDIS_PROXY_BACKEND, "proxy-a", "VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(signedVote, "VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(vote, "VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(VotingPluginWire.login("Alex",
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signedVote, "VotingPlugin_backend-a");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, vote, "VotingPlugin_backend-a");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, VotingPluginWire.login("Alex",
"00000000-0000-0000-0000-000000000001", "backend-a"), "VotingPlugin_backend-a");
verify(messages).onMessage(org.mockito.ArgumentMatchers.argThat(received ->
@@ -94,7 +100,7 @@ class RedisBackendProxyTransportTest {
@Test
void copiedRedisVoteCannotAuthenticateOnAnotherBackendChannel() throws Exception {
SharedTransportEnvelopeAuthenticator authenticator = authenticator();
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
setField(transport, "messageHandler", messages);
setField(transport, "authenticator", authenticator);
@@ -102,31 +108,31 @@ class RedisBackendProxyTransportTest {
JsonEnvelope signed = authenticator.sign(vote, Domain.REDIS_PROXY_BACKEND, "proxy-a",
"VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-b");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "VotingPlugin_backend-b");
verifyNoInteractions(messages);
transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "VotingPlugin_backend-a");
verify(messages).onMessage(org.mockito.ArgumentMatchers.any());
}
@Test
void exactAuthenticatedRedisReplayCannotDuplicateVoteProcessing() throws Exception {
SharedTransportEnvelopeAuthenticator authenticator = authenticator();
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
setField(transport, "messageHandler", messages);
setField(transport, "authenticator", authenticator);
JsonEnvelope signed = authenticator.sign(VotingPluginWire.withRedisDeliveryId(
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).build()), Domain.REDIS_PROXY_BACKEND, "proxy-a", "VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a");
transport.acceptAuthenticatedEnvelope(signed, "VotingPlugin_backend-a");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "VotingPlugin_backend-a");
RedisBackendProxyTransportAccess.acceptAuthenticatedEnvelope(transport, signed, "VotingPlugin_backend-a");
verify(messages, times(1)).onMessage(org.mockito.ArgumentMatchers.any());
}
@Test
void validationHonorsTlsAndHostnameVerification() {
DefaultJedisClientConfig config = RedisBackendProxyTransport.buildValidationClientConfig(3, "user", "secret",
DefaultJedisClientConfig config = RedisBackendProxyTransportAccess.buildValidationClientConfig(3, "user", "secret",
true);
assertTrue(config.isSsl());
@@ -143,20 +149,20 @@ class RedisBackendProxyTransportTest {
@Test
void validationKeepsTlsDisabledByDefault() {
assertFalse(RedisBackendProxyTransport.buildValidationClientConfig(0, null, null, false).isSsl());
assertFalse(RedisBackendProxyTransportAccess.buildValidationClientConfig(0, null, null, false).isSsl());
}
@Test
void legacyHandoffBufferDegradesAtItsFixedLimit() throws Exception {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
ReflectiveAccess.set(handler, transport, messages);
JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build();
for (int index = 0; index <= RedisBackendProxyTransport.MAX_LEGACY_HANDOFF_DELIVERIES; index++) {
transport.dispatchLegacy(envelope);
for (int index = 0; index <= RedisBackendProxyTransportAccess.MAX_LEGACY_HANDOFF_DELIVERIES; index++) {
RedisBackendProxyTransportAccess.dispatchLegacy(transport, envelope);
}
verify(messages, times(1)).onMessage(envelope);
@@ -164,45 +170,45 @@ class RedisBackendProxyTransportTest {
@Test
void legacyHandoffBufferDegradesForAnOversizedEnvelope() throws Exception {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
ReflectiveAccess.set(handler, transport, messages);
JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
.put("payload", "x".repeat(ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + 1)).build();
transport.dispatchLegacy(envelope);
RedisBackendProxyTransportAccess.dispatchLegacy(transport, envelope);
verify(messages).onMessage(envelope);
}
@Test
void retiredListenerDropsLegacyDeliveriesAfterShutdownTimeout() throws Exception {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
transport.fenceAfterHandoff();
ReflectiveAccess.set(handler, transport, messages);
RedisBackendProxyTransportAccess.fenceAfterHandoff(transport);
transport.dispatchLegacy(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build());
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build());
verifyNoInteractions(messages);
}
@Test
void retiredListenerDoesNotReserveOrDispatchDeliveryIds() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache);
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
transport.fenceAfterHandoff();
ReflectiveAccess.set(handler, transport, messages);
RedisBackendProxyTransportAccess.fenceAfterHandoff(transport);
String deliveryId = "00000000-0000-0000-0000-000000000001";
transport.dispatchIdentified(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(), deliveryId);
RedisBackendProxyTransportAccess.dispatchIdentified(transport, JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(), deliveryId);
assertTrue(cache.reserveRedisDelivery(deliveryId), "retired listener must not consume shared deduplication state");
verifyNoInteractions(messages);
@@ -210,7 +216,7 @@ class RedisBackendProxyTransportTest {
@Test
void standbyBuffersIdentifiedDeliveriesUntilItIsPublished() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
cache.registerRedisSubscriber(new Object());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache);
Field identity = RedisBackendProxyTransport.class.getDeclaredField("subscriberIdentity");
@@ -222,12 +228,12 @@ class RedisBackendProxyTransportTest {
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
ReflectiveAccess.set(handler, transport, messages);
String deliveryId = "00000000-0000-0000-0000-000000000002";
JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
.put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).build();
transport.dispatchIdentified(envelope, deliveryId);
RedisBackendProxyTransportAccess.dispatchIdentified(transport, envelope, deliveryId);
verifyNoInteractions(messages);
transport.activateAfterHandoff();
@@ -252,8 +258,8 @@ class RedisBackendProxyTransportTest {
JsonEnvelope identified = JsonEnvelope.builder("identified")
.put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).build();
transport.dispatchLegacy(legacy);
transport.dispatchIdentified(identified, deliveryId);
RedisBackendProxyTransportAccess.dispatchLegacy(transport, legacy);
RedisBackendProxyTransportAccess.dispatchIdentified(transport, identified, deliveryId);
transport.activateAfterHandoff();
transport.replayAfterHandoffPublication();
@@ -264,7 +270,7 @@ class RedisBackendProxyTransportTest {
@Test
void legacyCopiesDoNotExposeReliableDeliveryIdentity() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache);
Field identity = RedisBackendProxyTransport.class.getDeclaredField("subscriberIdentity");
identity.setAccessible(true);
@@ -275,7 +281,7 @@ class RedisBackendProxyTransportTest {
JsonEnvelope mixed = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE)
.put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).put("payload", "legacy").build();
transport.dispatchLegacy(mixed);
RedisBackendProxyTransportAccess.dispatchLegacy(transport, mixed);
org.mockito.ArgumentCaptor<JsonEnvelope> captured = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class);
verify(messages).onMessage(captured.capture());
@@ -288,9 +294,9 @@ class RedisBackendProxyTransportTest {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, mock(ProcessedVoteCache.class));
setBoolean(transport, "standbySubscriber", true);
setField(transport, "messageHandler", mock(GlobalMessageHandler.class));
for (int index = 0; index <= RedisBackendProxyTransport.MAX_IDENTIFIED_HANDOFF_DELIVERIES; index++) {
for (int index = 0; index <= RedisBackendProxyTransportAccess.MAX_IDENTIFIED_HANDOFF_DELIVERIES; index++) {
String deliveryId = String.format("00000000-0000-0000-0000-%012d", index);
transport.dispatchIdentified(JsonEnvelope.builder("identified")
RedisBackendProxyTransportAccess.dispatchIdentified(transport, JsonEnvelope.builder("identified")
.put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).build(), deliveryId);
}
@@ -306,7 +312,7 @@ class RedisBackendProxyTransportTest {
JsonEnvelope oversized = JsonEnvelope.builder("legacy")
.put("payload", "x".repeat(ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + 1)).build();
transport.dispatchLegacy(oversized);
RedisBackendProxyTransportAccess.dispatchLegacy(transport, oversized);
verifyNoInteractions(messages);
assertThrows(IllegalStateException.class, transport::activateAfterHandoff);
@@ -319,8 +325,8 @@ class RedisBackendProxyTransportTest {
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
setField(transport, "messageHandler", messages);
for (int index = 0; index <= RedisBackendProxyTransport.MAX_LEGACY_HANDOFF_DELIVERIES; index++)
transport.dispatchLegacy(JsonEnvelope.builder("legacy-" + index).build());
for (int index = 0; index <= RedisBackendProxyTransportAccess.MAX_LEGACY_HANDOFF_DELIVERIES; index++)
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder("legacy-" + index).build());
verifyNoInteractions(messages);
assertThrows(IllegalStateException.class, transport::activateAfterHandoff);
@@ -328,7 +334,7 @@ class RedisBackendProxyTransportTest {
@Test
void activeLegacyAccountingOverflowAbortsLaggingStandbyPublication() throws Exception {
ProcessedVoteCache cache = new ProcessedVoteCache();
ProcessedVoteCache cache = ProcessedVoteCacheAccess.create();
Object previousIdentity = new Object();
cache.registerRedisSubscriber(previousIdentity);
RedisBackendProxyTransport replacement = new RedisBackendProxyTransport(null, cache);
@@ -346,7 +352,7 @@ class RedisBackendProxyTransportTest {
@Test
void fencingWaitsForAnIdentifiedMessageHandler() throws Exception {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache());
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, ProcessedVoteCacheAccess.create());
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
@@ -361,8 +367,8 @@ class RedisBackendProxyTransportTest {
}).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class));
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
Thread delivery = new Thread(() -> transport.dispatchIdentified(
ReflectiveAccess.set(handler, transport, messages);
Thread delivery = new Thread(() -> RedisBackendProxyTransportAccess.dispatchIdentified(transport,
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(), "delivery"));
delivery.start();
assertTrue(entered.await(1, TimeUnit.SECONDS));
@@ -372,7 +378,7 @@ class RedisBackendProxyTransportTest {
new java.util.concurrent.atomic.AtomicReference<>();
Thread retirement = new Thread(() -> {
try {
transport.fenceAfterHandoff();
RedisBackendProxyTransportAccess.fenceAfterHandoff(transport);
} catch (Throwable failure) {
fenceFailure.set(failure);
} finally {
@@ -416,7 +422,7 @@ class RedisBackendProxyTransportTest {
}).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class));
setField(transport, "messageHandler", messages);
Thread delivery = new Thread(() -> transport.dispatchLegacy(
Thread delivery = new Thread(() -> RedisBackendProxyTransportAccess.dispatchLegacy(transport,
JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build()));
delivery.start();
assertTrue(callbackEntered.await(1, TimeUnit.SECONDS));
@@ -475,8 +481,8 @@ class RedisBackendProxyTransportTest {
JsonEnvelope identified = JsonEnvelope.builder("identified-overlap").build();
setBoolean(standby, "standbySubscriber", true);
setField(standby, "messageHandler", mock(GlobalMessageHandler.class));
standby.dispatchLegacy(legacy);
standby.dispatchIdentified(identified, "00000000-0000-0000-0000-000000001594");
RedisBackendProxyTransportAccess.dispatchLegacy(standby, legacy);
RedisBackendProxyTransportAccess.dispatchIdentified(standby, identified, "00000000-0000-0000-0000-000000001594");
org.mockito.Mockito.doThrow(new IllegalStateException("standby promotion rejected"))
.when(standby).activateAfterHandoff();
setField(previous, "transport", oldTransport);
@@ -533,13 +539,13 @@ class RedisBackendProxyTransportTest {
setField(replacement, "messageHandler", replacementMessages);
JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build();
Thread delivery = new Thread(() -> previous.dispatchLegacy(envelope));
Thread delivery = new Thread(() -> RedisBackendProxyTransportAccess.dispatchLegacy(previous, envelope));
delivery.start();
assertTrue(entered.await(1, TimeUnit.SECONDS));
replacement.dispatchLegacy(envelope);
RedisBackendProxyTransportAccess.dispatchLegacy(replacement, envelope);
CountDownLatch promoted = new CountDownLatch(1);
Thread promotion = new Thread(() -> {
previous.fenceAfterHandoff();
RedisBackendProxyTransportAccess.fenceAfterHandoff(previous);
replacement.activateAfterHandoff();
promoted.countDown();
});
@@ -568,7 +574,7 @@ class RedisBackendProxyTransportTest {
JsonEnvelope envelope = invocation.getArgument(0);
order.add(envelope);
if (envelope == replayed) {
Thread arrival = new Thread(() -> transport.dispatchIdentified(newer, "new-delivery"));
Thread arrival = new Thread(() -> RedisBackendProxyTransportAccess.dispatchIdentified(transport, newer, "new-delivery"));
arrival.start();
arrival.join(TimeUnit.SECONDS.toMillis(1));
}
@@ -576,8 +582,8 @@ class RedisBackendProxyTransportTest {
}).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class));
Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler");
handler.setAccessible(true);
handler.set(transport, messages);
transport.dispatchLegacy(replayed);
ReflectiveAccess.set(handler, transport, messages);
RedisBackendProxyTransportAccess.dispatchLegacy(transport, replayed);
transport.activateAfterHandoff();
transport.replayAfterHandoffPublication();
@@ -601,15 +607,15 @@ class RedisBackendProxyTransportTest {
when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false);
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache);
setBoolean(transport, "standbySubscriber", true);
CountDownLatch replayed = new CountDownLatch(RedisBackendProxyTransport.REPLAY_BATCH_SIZE + 1);
CountDownLatch replayed = new CountDownLatch(RedisBackendProxyTransportAccess.REPLAY_BATCH_SIZE + 1);
GlobalMessageHandler messages = mock(GlobalMessageHandler.class);
doAnswer(invocation -> {
replayed.countDown();
return null;
}).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class));
setField(transport, "messageHandler", messages);
for (int index = 0; index <= RedisBackendProxyTransport.REPLAY_BATCH_SIZE; index++)
transport.dispatchLegacy(JsonEnvelope.builder("replay-" + index).build());
for (int index = 0; index <= RedisBackendProxyTransportAccess.REPLAY_BATCH_SIZE; index++)
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder("replay-" + index).build());
transport.activateAfterHandoff();
transport.replayAfterHandoffPublication();
@@ -630,14 +636,14 @@ class RedisBackendProxyTransportTest {
queueField.setAccessible(true);
@SuppressWarnings("unchecked")
java.util.ArrayDeque<JsonEnvelope> queue = (java.util.ArrayDeque<JsonEnvelope>) queueField.get(transport);
for (int index = 0; index < RedisBackendProxyTransport.MAX_REPLAY_HANDOFF_DELIVERIES; index++)
for (int index = 0; index < RedisBackendProxyTransportAccess.MAX_REPLAY_HANDOFF_DELIVERIES; index++)
queue.addLast(JsonEnvelope.builder("queued-" + index).build());
JsonEnvelope newer = JsonEnvelope.builder("new").build();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch returned = new CountDownLatch(1);
Thread callback = new Thread(() -> {
entered.countDown();
transport.dispatchIdentified(newer, "new-delivery");
RedisBackendProxyTransportAccess.dispatchIdentified(transport, newer, "new-delivery");
returned.countDown();
});
callback.start();
@@ -662,12 +668,12 @@ class RedisBackendProxyTransportTest {
setField(transport, "messageHandler", mock(GlobalMessageHandler.class));
setBoolean(transport, "replayingHandoff", true);
java.util.ArrayDeque<JsonEnvelope> queue = replayQueue(transport);
for (int index = 0; index < RedisBackendProxyTransport.MAX_REPLAY_HANDOFF_DELIVERIES; index++)
for (int index = 0; index < RedisBackendProxyTransportAccess.MAX_REPLAY_HANDOFF_DELIVERIES; index++)
queue.addLast(JsonEnvelope.builder("queued-" + index).build());
JsonEnvelope received = JsonEnvelope.builder("received").build();
CountDownLatch completed = new CountDownLatch(1);
Thread subscriber = new Thread(() -> {
transport.dispatchReceivedSubscriberEnvelope(received, "received-delivery");
RedisBackendProxyTransportAccess.dispatchReceivedSubscriberEnvelope(transport, received, "received-delivery");
completed.countDown();
});
subscriber.start();
@@ -700,11 +706,11 @@ class RedisBackendProxyTransportTest {
JsonEnvelope before = JsonEnvelope.builder("before").build();
JsonEnvelope during = JsonEnvelope.builder("during").build();
replacement.dispatchIdentified(before, "before-publication");
RedisBackendProxyTransportAccess.dispatchIdentified(replacement, before, "before-publication");
replacement.activateAfterHandoff();
// This is the narrow interval after worker-side promotion but before Bukkit
// opens publication/replay. It has already reserved its Redis identity.
replacement.dispatchIdentified(during, "during-publication");
RedisBackendProxyTransportAccess.dispatchIdentified(replacement, during, "during-publication");
assertEquals(java.util.List.of(before, during), replacement.detachReplayForFailedHandoff());
assertTrue(replayQueue(replacement).isEmpty());
@@ -734,7 +740,7 @@ class RedisBackendProxyTransportTest {
setField(replacement, "messageHandler", messages);
replacement.acceptReplayFromPreviousHandoff(transferred);
JsonEnvelope newer = JsonEnvelope.builder("newer").build();
replacement.dispatchIdentified(newer, "new-overlap");
RedisBackendProxyTransportAccess.dispatchIdentified(replacement, newer, "new-overlap");
replacement.activateAfterHandoff();
replacement.replayAfterHandoffPublication();
@@ -754,7 +760,7 @@ class RedisBackendProxyTransportTest {
JsonEnvelope duringFreeze = JsonEnvelope.builder("during-freeze").build();
CountDownLatch completed = new CountDownLatch(1);
Thread callback = new Thread(() -> {
previous.dispatchIdentified(duringFreeze, "during-freeze");
RedisBackendProxyTransportAccess.dispatchIdentified(previous, duringFreeze, "during-freeze");
completed.countDown();
});
callback.start();
@@ -778,7 +784,7 @@ class RedisBackendProxyTransportTest {
previous.freezeReplayForSuccessiveHandoff();
CountDownLatch callbackDone = new CountDownLatch(1);
Thread callback = new Thread(() -> {
previous.dispatchIdentified(JsonEnvelope.builder("during-close").build(), "during-close");
RedisBackendProxyTransportAccess.dispatchIdentified(previous, JsonEnvelope.builder( "during-close").build(), "during-close");
callbackDone.countDown();
});
callback.start();
@@ -823,7 +829,7 @@ class RedisBackendProxyTransportTest {
@Test
void retiredRedisCleanupDoesNotBlockManagerCloseOnPublicationThread() throws Exception {
BackendProxyTransportManager manager = new BackendProxyTransportManager(null, new ProcessedVoteCache());
BackendProxyTransportManager manager = new BackendProxyTransportManager(null, ProcessedVoteCacheAccess.create());
RedisBackendProxyTransport retired = mock(RedisBackendProxyTransport.class);
CountDownLatch closeEntered = new CountDownLatch(1);
CountDownLatch releaseClose = new CountDownLatch(1);
@@ -860,7 +866,7 @@ class RedisBackendProxyTransportTest {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache);
setBoolean(transport, "standbySubscriber", true);
setField(transport, "messageHandler", mock(GlobalMessageHandler.class));
transport.dispatchLegacy(JsonEnvelope.builder("replay").build());
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder("replay").build());
transport.activateAfterHandoff();
transport.replayAfterHandoffPublication();
@@ -889,7 +895,7 @@ class RedisBackendProxyTransportTest {
RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache);
setBoolean(transport, "standbySubscriber", true);
setField(transport, "messageHandler", mock(GlobalMessageHandler.class));
transport.dispatchLegacy(JsonEnvelope.builder("replay").build());
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder("replay").build());
transport.activateAfterHandoff();
transport.replayAfterHandoffPublication();
assertTrue(submitted.await(1, TimeUnit.SECONDS));
@@ -930,8 +936,8 @@ class RedisBackendProxyTransportTest {
return null;
}).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class));
setField(transport, "messageHandler", messages);
transport.dispatchLegacy(JsonEnvelope.builder("first").build());
transport.dispatchLegacy(JsonEnvelope.builder("second").build());
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder("first").build());
RedisBackendProxyTransportAccess.dispatchLegacy(transport, JsonEnvelope.builder("second").build());
transport.activateAfterHandoff();
transport.replayAfterHandoffPublication();
assertTrue(entered.await(1, TimeUnit.SECONDS));
@@ -961,7 +967,7 @@ class RedisBackendProxyTransportTest {
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
ReflectiveAccess.set(field, target, value);
}
private static java.util.List<JsonEnvelope> preparedSendQueue(BackendProxyTransportManager manager)
@@ -0,0 +1,27 @@
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backendproxy.transport.*;
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
/** Test-only forwarding access to the non-public production SharedInboundPolicy. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class SharedInboundPolicyAccess implements ReflectiveAccess.View {
private final Object delegate;
private SharedInboundPolicyAccess(Object delegate) { this.delegate = delegate; }
public static SharedInboundPolicyAccess view(Object delegate) { return delegate == null ? null : new SharedInboundPolicyAccess(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof SharedInboundPolicyAccess value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public static SharedInboundPolicyAccess mock() { return view(org.mockito.Mockito.mock(ReflectiveAccess.type("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy"))); }
public SharedInboundPolicyAccess verify() { return view(org.mockito.Mockito.verify(delegate)); }
public SharedInboundPolicyAccess verify(org.mockito.verification.VerificationMode mode) { return view(org.mockito.Mockito.verify(delegate, mode)); }
public SharedInboundPolicyAccess stub(org.mockito.stubbing.Stubber stubber) { return view(stubber.when(delegate)); }
public SharedInboundPolicyAccess(Class<? extends BackendProxyTransport> transportType, String destination, SharedTransportEnvelopeAuthenticator authenticator, TransportEnvelopeEncryption encryption) { this(ReflectiveAccess.construct("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy", new String[]{"Class", "String", "SharedTransportEnvelopeAuthenticator", "TransportEnvelopeEncryption"}, new Object[]{transportType, destination, authenticator, encryption})); }
public Class<? extends BackendProxyTransport> transportType() { return (Class<? extends BackendProxyTransport>) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy", delegate, "transportType", new String[]{}, new Object[]{}); }
public String destination() { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy", delegate, "destination", new String[]{}, new Object[]{}); }
public SharedTransportEnvelopeAuthenticator authenticator() { return (SharedTransportEnvelopeAuthenticator) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy", delegate, "authenticator", new String[]{}, new Object[]{}); }
public TransportEnvelopeEncryption encryption() { return (TransportEnvelopeEncryption) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy", delegate, "encryption", new String[]{}, new Object[]{}); }
public boolean hasEquivalentPolicy(SharedInboundPolicyAccess other) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.backendproxy.transport.SharedInboundPolicy", delegate, "hasEquivalentPolicy", new String[]{"SharedInboundPolicy"}, new Object[]{other}); }
}
@@ -1,4 +1,8 @@
package com.bencodez.votingplugin.backendproxy.transport;
package com.bencodez.votingplugin.tests.backendproxy.transport;
import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport;
import com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport;
import com.bencodez.votingplugin.tests.backendproxy.transport.SharedInboundPolicyAccess;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -24,19 +28,19 @@ class SharedInboundPolicyTest {
SharedTransportEnvelopeAuthenticator required = SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED);
TransportEnvelopeEncryption encrypted = TransportEnvelopeEncryption.load(keyFile,
TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true);
SharedInboundPolicy policy = new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a", required,
SharedInboundPolicyAccess policy = new SharedInboundPolicyAccess(RedisBackendProxyTransport.class, "vp:a", required,
encrypted);
assertTrue(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a",
assertTrue(policy.hasEquivalentPolicy(new SharedInboundPolicyAccess(RedisBackendProxyTransport.class, "vp:a",
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), TransportEnvelopeEncryption.load(keyFile,
TransportEnvelopeEncryption.Domain.PROXY_BACKEND, true))));
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:b",
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicyAccess(RedisBackendProxyTransport.class, "vp:b",
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), encrypted)));
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(MqttBackendProxyTransport.class, "vp:a",
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicyAccess(MqttBackendProxyTransport.class, "vp:a",
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), encrypted)));
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a",
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicyAccess(RedisBackendProxyTransport.class, "vp:a",
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.COMPATIBILITY), encrypted)));
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicy(RedisBackendProxyTransport.class, "vp:a",
assertFalse(policy.hasEquivalentPolicy(new SharedInboundPolicyAccess(RedisBackendProxyTransport.class, "vp:a",
SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED), TransportEnvelopeEncryption.load(keyFile,
TransportEnvelopeEncryption.Domain.PROXY_BACKEND, false))));
}
@@ -0,0 +1,14 @@
package com.bencodez.votingplugin.tests.backgroundtask;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.backgroundtask.*;
import com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask.*;
import com.bencodez.votingplugin.VotingPluginMain;
/** Typed test-only access to package-private VotingPluginBackgroundTask seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class VotingPluginBackgroundTaskAccess {
private VotingPluginBackgroundTaskAccess() { }
public static VotingPluginBackgroundTask create(VotingPluginMain plugin) { return (VotingPluginBackgroundTask) ReflectiveAccess.construct("com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask", new String[]{"VotingPluginMain"}, new Object[]{plugin}); }
public static VotingPluginBackgroundTask create(VotingPluginMain plugin, long snapshotWaitMillis) { return (VotingPluginBackgroundTask) ReflectiveAccess.construct("com.bencodez.votingplugin.backgroundtask.VotingPluginBackgroundTask", new String[]{"VotingPluginMain", "long"}, new Object[]{plugin, snapshotWaitMillis}); }
}
@@ -1,6 +1,8 @@
package com.bencodez.votingplugin.tests.backgroundtask;
import com.bencodez.votingplugin.util.EntityTaskResultTestCompat;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.tests.util.EntityTaskResultTestCompat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -28,7 +30,7 @@ public class VotingPluginBackgroundTaskTest {
@Test
public void requestedStateCanBeDelegated() {
VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(mock(VotingPluginMain.class));
VotingPluginBackgroundTask task = VotingPluginBackgroundTaskAccess.create(mock(VotingPluginMain.class));
assertFalse(task.isRequested());
task.setRequested(true);
assertTrue(task.isRequested());
@@ -46,7 +48,7 @@ public class VotingPluginBackgroundTaskTest {
new java.util.concurrent.atomic.AtomicReference<>();
org.mockito.Mockito.doAnswer(call -> { callback.set(call.getArgument(0)); return null; })
.when(plugin).captureOnlineTopVoterIgnore(any(), any());
VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin);
VotingPluginBackgroundTask task = VotingPluginBackgroundTaskAccess.create(plugin);
task.setRequested(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
@@ -74,7 +76,7 @@ public class VotingPluginBackgroundTaskTest {
.when(plugin).captureOnlineTopVoterIgnore(any(), any());
java.util.concurrent.ScheduledExecutorService storage = mock(java.util.concurrent.ScheduledExecutorService.class);
when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(storage);
VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin);
VotingPluginBackgroundTask task = VotingPluginBackgroundTaskAccess.create(plugin);
task.setRequested(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
@@ -124,7 +126,7 @@ public class VotingPluginBackgroundTaskTest {
java.lang.reflect.Constructor<VotingPluginBackgroundTask> constructor =
VotingPluginBackgroundTask.class.getDeclaredConstructor(VotingPluginMain.class, long.class);
constructor.setAccessible(true);
VotingPluginBackgroundTask task = constructor.newInstance(plugin, 10L);
VotingPluginBackgroundTask task = ReflectiveAccess.newInstance(constructor, new Object[]{plugin, 10L});
task.setRequested(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
@@ -147,7 +149,7 @@ public class VotingPluginBackgroundTaskTest {
when(plugin.getConfigFile()).thenReturn(config);
doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; })
.when(plugin).captureOnlineTopVoterIgnore(any(), any());
VotingPluginBackgroundTask task = new VotingPluginBackgroundTask(plugin);
VotingPluginBackgroundTask task = VotingPluginBackgroundTaskAccess.create(plugin);
task.setRequested(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
@@ -174,7 +176,7 @@ public class VotingPluginBackgroundTaskTest {
java.lang.reflect.Constructor<VotingPluginBackgroundTask> constructor =
VotingPluginBackgroundTask.class.getDeclaredConstructor(VotingPluginMain.class, long.class);
constructor.setAccessible(true);
VotingPluginBackgroundTask task = constructor.newInstance(plugin, 10L);
VotingPluginBackgroundTask task = ReflectiveAccess.newInstance(constructor, new Object[]{plugin, 10L});
task.setRequested(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
@@ -1,4 +1,4 @@
package com.bencodez.votingplugin.build;
package com.bencodez.votingplugin.tests.build;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.commands;
package com.bencodez.votingplugin.tests.commands;
import com.bencodez.votingplugin.commands.AdminAuthorization;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -0,0 +1,44 @@
package com.bencodez.votingplugin.tests.commands;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.commands.*;
import com.bencodez.votingplugin.commands.CommandLoader.*;
import java.util.List;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.PluginManager;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.user.PointTransferResult;
/** Typed test-only access to package-private CommandLoader seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class CommandLoaderAccess {
public static void registerOfflineVoteClearCommands(CommandLoader target, String adminPerm) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "registerOfflineVoteClearCommands", new String[]{"String"}, new Object[]{adminPerm}); }
public static void runConfirmedBulkStorageMutation(CommandLoader target, CommandSender sender, java.util.function.BooleanSupplier mutation, Runnable success) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "runConfirmedBulkStorageMutation", new String[]{"CommandSender", "BooleanSupplier", "Runnable"}, new Object[]{sender, mutation, success}); }
private CommandLoaderAccess() { }
public static void runForCommandSender(CommandLoader target, CommandSender sender, Runnable task) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "runForCommandSender", new String[]{"CommandSender", "Runnable"}, new Object[]{sender, task}); }
public static void runForVotingUser(CommandLoader target, VotingPluginUser user, Runnable task) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "runForVotingUser", new String[]{"VotingPluginUser", "Runnable"}, new Object[]{user, task}); }
public static void runBulkStorageMutation(CommandLoader target, CommandSender sender, Runnable mutation, Runnable success) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "runBulkStorageMutation", new String[]{"CommandSender", "Runnable", "Runnable"}, new Object[]{sender, mutation, success}); }
public static void loadAllVotingUsersAsync(CommandLoader target, CommandSender sender, java.util.function.Consumer<CommandLoaderAccess.BulkVotingUsers> success) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "loadAllVotingUsersAsync", new String[]{"CommandSender", "Consumer"}, new Object[]{sender, (java.util.function.Consumer<Object>) value -> success.accept(BulkVotingUsers.view(value))}); }
public static String transferFailureMessage(CommandLoader target, PointTransferResult result) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", target, "transferFailureMessage", new String[]{"PointTransferResult"}, new Object[]{result}); }
public static PlayerCommandHandler configureAllPermissionOverride(PlayerCommandHandler handler, String adminPermission) { return (PlayerCommandHandler) ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", null, "configureAllPermissionOverride", new String[]{"PlayerCommandHandler", "String"}, new Object[]{handler, adminPermission}); }
public static void registerAdditionalPermissions(PluginManager manager, CommandHandler handler) { ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader", null, "registerAdditionalPermissions", new String[]{"PluginManager", "CommandHandler"}, new Object[]{manager, handler}); }
public static final class BulkVotingUsers implements ReflectiveAccess.View {
private final Object delegate;
private BulkVotingUsers(Object delegate) { this.delegate = delegate; }
public static BulkVotingUsers view(Object delegate) { return delegate == null ? null : new BulkVotingUsers(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof BulkVotingUsers value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public java.util.List<VotingPluginUser> users() { return (java.util.List<VotingPluginUser>) ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader$BulkVotingUsers", delegate, "users", new String[]{}, new Object[]{}); }
public java.util.IdentityHashMap<VotingPluginUser, Player> players() { return (java.util.IdentityHashMap<VotingPluginUser, Player>) ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader$BulkVotingUsers", delegate, "players", new String[]{}, new Object[]{}); }
public Player player(VotingPluginUser user) { return (Player) ReflectiveAccess.call("com.bencodez.votingplugin.commands.CommandLoader$BulkVotingUsers", delegate, "player", new String[]{"VotingPluginUser"}, new Object[]{user}); }
}
}
@@ -1,4 +1,7 @@
package com.bencodez.votingplugin.commands;
package com.bencodez.votingplugin.tests.commands;
import com.bencodez.votingplugin.commands.AdminAuthorization;
import com.bencodez.votingplugin.commands.CommandLoader;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
@@ -26,12 +29,12 @@ class CommandLoaderBulkPermissionTest {
PlayerCommandHandler handler = mock(PlayerCommandHandler.class);
try {
PlayerCommandHandler.class.getMethod("withAllPermissionOverrides", String[].class);
assertSame(handler, CommandLoader.configureAllPermissionOverride(handler,
assertSame(handler, CommandLoaderAccess.configureAllPermissionOverride(handler,
AdminAuthorization.ADMIN_PERMISSION));
assertTrue(mockingDetails(handler).getInvocations().stream()
.anyMatch(invocation -> invocation.getMethod().getName().equals("withAllPermissionOverrides")));
} catch (NoSuchMethodException exception) {
assertThrows(IllegalStateException.class, () -> CommandLoader.configureAllPermissionOverride(handler,
assertThrows(IllegalStateException.class, () -> CommandLoaderAccess.configureAllPermissionOverride(handler,
AdminAuthorization.ADMIN_PERMISSION));
}
}
@@ -44,7 +47,7 @@ class CommandLoaderBulkPermissionTest {
.thenReturn(List.of("VotingPlugin.Commands.AdminVote.SetPoints.All"));
when(manager.getPermission("VotingPlugin.Commands.AdminVote.SetPoints.All")).thenReturn(null);
CommandLoader.registerAdditionalPermissions(manager, handler);
CommandLoaderAccess.registerAdditionalPermissions(manager, handler);
ArgumentCaptor<Permission> registered = ArgumentCaptor.forClass(Permission.class);
verify(manager).addPermission(registered.capture());
@@ -1,6 +1,8 @@
package com.bencodez.votingplugin.commands;
package com.bencodez.votingplugin.tests.commands;
import com.bencodez.votingplugin.util.EntityTaskResultTestCompat;
import com.bencodez.votingplugin.commands.CommandLoader;
import com.bencodez.votingplugin.tests.util.EntityTaskResultTestCompat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -37,7 +39,7 @@ class CommandLoaderSchedulingTest {
Player sender = mock(Player.class);
Runnable completion = () -> { };
new CommandLoader(plugin).runForCommandSender(sender, completion);
CommandLoaderAccess.runForCommandSender(new CommandLoader(plugin), sender, completion);
verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(sender));
verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class));
@@ -52,7 +54,7 @@ class CommandLoaderSchedulingTest {
CommandSender sender = mock(CommandSender.class);
Runnable completion = () -> { };
new CommandLoader(plugin).runForCommandSender(sender, completion);
CommandLoaderAccess.runForCommandSender(new CommandLoader(plugin), sender, completion);
verify(scheduler).runTask(eq(plugin), any(Runnable.class));
verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class), any(Player.class));
@@ -69,7 +71,7 @@ class CommandLoaderSchedulingTest {
when(user.getPlayer()).thenReturn(recipient);
Runnable completion = () -> { };
new CommandLoader(plugin).runForVotingUser(user, completion);
CommandLoaderAccess.runForVotingUser(new CommandLoader(plugin), user, completion);
verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(recipient));
verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class));
@@ -83,7 +85,7 @@ class CommandLoaderSchedulingTest {
VotingPluginUser user = mock(VotingPluginUser.class);
Runnable completion = () -> { };
new CommandLoader(plugin).runForVotingUser(user, completion);
CommandLoaderAccess.runForVotingUser(new CommandLoader(plugin), user, completion);
verify(scheduler).runTask(eq(plugin), any(Runnable.class));
verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class), any(Player.class));
@@ -100,11 +102,11 @@ class CommandLoaderSchedulingTest {
CommandLoader loader = new CommandLoader(plugin);
org.junit.jupiter.api.Assertions.assertEquals("insufficient",
loader.transferFailureMessage(PointTransferResult.INSUFFICIENT_POINTS));
org.junit.jupiter.api.Assertions.assertEquals("retry", loader.transferFailureMessage(PointTransferResult.CANCELLED));
org.junit.jupiter.api.Assertions.assertEquals("retry", loader.transferFailureMessage(PointTransferResult.UNAVAILABLE));
CommandLoaderAccess.transferFailureMessage(loader, PointTransferResult.INSUFFICIENT_POINTS));
org.junit.jupiter.api.Assertions.assertEquals("retry", CommandLoaderAccess.transferFailureMessage(loader, PointTransferResult.CANCELLED));
org.junit.jupiter.api.Assertions.assertEquals("retry", CommandLoaderAccess.transferFailureMessage(loader, PointTransferResult.UNAVAILABLE));
org.junit.jupiter.api.Assertions.assertEquals("pending; do not retry",
loader.transferFailureMessage(PointTransferResult.PENDING_CONFIRMATION));
CommandLoaderAccess.transferFailureMessage(loader, PointTransferResult.PENDING_CONFIRMATION));
}
@Test
@@ -135,7 +137,7 @@ class CommandLoaderSchedulingTest {
.when(storage).execute(any(Runnable.class));
AtomicBoolean storageRan = new AtomicBoolean();
new CommandLoader(plugin).runBulkStorageMutation(sender, () -> storageRan.set(true), () -> { });
CommandLoaderAccess.runBulkStorageMutation(new CommandLoader(plugin), sender, () -> storageRan.set(true), () -> { });
org.junit.jupiter.api.Assertions.assertFalse(storageRan.get());
org.junit.jupiter.api.Assertions.assertNotNull(worker.get());
@@ -163,9 +165,9 @@ class CommandLoaderSchedulingTest {
AtomicReference<Runnable> platform = new AtomicReference<>();
org.mockito.Mockito.doAnswer(call -> { platform.set(call.getArgument(1)); return null; })
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
AtomicReference<CommandLoader.BulkVotingUsers> delivered = new AtomicReference<>();
AtomicReference<CommandLoaderAccess.BulkVotingUsers> delivered = new AtomicReference<>();
new CommandLoader(plugin).loadAllVotingUsersAsync(sender, delivered::set);
CommandLoaderAccess.loadAllVotingUsersAsync(new CommandLoader(plugin), sender, delivered::set);
storageTasks.get(0).run();
verify(user, never()).getPlayer();
platform.get().run();
@@ -196,7 +198,7 @@ class CommandLoaderSchedulingTest {
org.mockito.Mockito.doAnswer(call -> { platformTasks.add(call.getArgument(1)); return null; })
.when(scheduler).runTask(eq(plugin), any(Runnable.class));
new CommandLoader(plugin).loadAllVotingUsersAsync(sender, ignored -> { });
CommandLoaderAccess.loadAllVotingUsersAsync(new CommandLoader(plugin), sender, ignored -> { });
storageTasks.get(0).run();
org.junit.jupiter.api.Assertions.assertEquals(1, platformTasks.size());
platformTasks.get(0).run();
@@ -0,0 +1,494 @@
package com.bencodez.votingplugin.tests.commands;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.commands.CommandLoader;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandMap;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import com.bencodez.advancedcore.AdvancedCoreConfigOptions;
import com.bencodez.advancedcore.api.command.CommandHandler;
import io.github.projectunified.unidialog.core.payload.DialogPayload;
import com.bencodez.simpleapi.dialog.UniDialogService;
import com.bencodez.simpleapi.dialog.MultiActionDialogBuilder;
import com.bencodez.simpleapi.scheduler.BukkitScheduler;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.commands.gui.player.VoteHelp;
import com.bencodez.votingplugin.commands.gui.player.VoteURL;
import com.bencodez.votingplugin.config.Config;
import com.bencodez.votingplugin.config.GUI;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.votesites.VoteSiteManager;
class ConfigurableVoteCommandTest {
@Test
void absentConfigurationKeysLoadTheExistingDefaults() throws Exception {
Config config = mock(Config.class);
new com.bencodez.simpleapi.file.annotation.AnnotationHandler()
.load(new org.bukkit.configuration.file.YamlConfiguration(), config);
var enabled = Config.class.getDeclaredField("voteCommandEnabled");
var name = Config.class.getDeclaredField("voteCommandName");
enabled.setAccessible(true);
name.setAccessible(true);
assertTrue(enabled.getBoolean(config));
assertEquals("vote", name.get(config));
}
@Test
void defaultRootKeepsDispatchPermissionsStandaloneAliasesAndTabCompletion() throws Exception {
try (Fixture f = new Fixture(true, "vote", true)) {
PluginCommand original = f.owned.get("vote");
f.loader.loadAliases();
assertSame(original, f.map.getCommand("vote"));
assertSame(original, f.map.getCommand("votingplugin:vote"));
assertSame(f.owned.get("votegui"), f.map.getCommand("vgui"));
assertEquals("VotingPlugin.Commands.Vote.GUI", f.owned.get("votegui").getPermission());
assertEquals(List.of("GUI", "Help"), original.tabComplete(f.player, "vote", new String[] { "" }));
assertTrue(f.map.dispatch(f.player, "vote GUI"));
assertEquals(1, f.executed.get());
assertEquals("/vote", f.plugin.getVoteCommandPrefix());
}
}
@Test
void disableRemovesAllOwnedPlayerLabelsAndNamespacesButKeepsAdminAndForeignCommands() throws Exception {
try (Fixture f = new Fixture(false, "vote", true)) {
Command foreign = f.foreign("votechallenge", "other");
f.loader.loadAliases();
for (String name : List.of("vote", "votehelp", "vhelp", "votegui", "vgui")) {
assertNull(f.map.getCommand(name), name);
assertNull(f.map.getCommand("votingplugin:" + name), name);
}
assertSame(f.owned.get("adminvote"), f.map.getCommand("adminvote"));
assertSame(f.owned.get("av"), f.map.getCommand("av"));
assertSame(f.owned.get("adminvotehelp"), f.map.getCommand("avhelp"));
assertNotNull(f.owned.get("adminvotehelp").getExecutor());
assertSame(foreign, f.map.getCommand("votechallenge"));
assertFalse(f.plugin.isVoteCommandEnabled());
assertFalse(f.map.dispatch(f.player, "votingplugin:vote GUI"));
f.loader.processSlotClick(f.player, mock(VotingPluginUser.class), "help");
verify(f.player, never()).performCommand(anyString());
assertTrue(new VoteHelp(f.plugin, f.player, 1).helpText(f.player).isEmpty());
}
}
@Test
void renameRemovesOldRootAndRetainsCanonicalStandaloneAliases() throws Exception {
try (Fixture f = new Fixture(true, " Ballot ", true)) {
f.loader.loadAliases();
assertNull(f.map.getCommand("vote"));
assertNull(f.map.getCommand("votingplugin:vote"));
assertSame(f.owned.get("vote"), f.map.getCommand("ballot"));
assertSame(f.owned.get("vote"), f.map.getCommand("votingplugin:ballot"));
assertSame(f.owned.get("votegui"), f.map.getCommand("vgui"));
assertNull(f.map.getCommand("ballotgui"));
assertEquals("/ballot", f.plugin.getVoteCommandPrefix());
assertTrue(f.map.dispatch(f.player, "ballot GUI"));
assertTrue(f.map.dispatch(f.player, "vgui"));
assertEquals(2, f.executed.get());
assertEquals(List.of("GUI", "Help"), f.owned.get("vote").tabComplete(f.player, "ballot", new String[] { "" }));
assertEquals("VotingPlugin.Commands.Vote.GUI", f.owned.get("votegui").getPermission());
assertTrue(f.loader.hasPermission(f.player, "ballot GUI"));
}
}
@Test
void syntheticPlayerAndAdminAliasesCannotReplaceRenamedRoot() throws Exception {
for (boolean aliases : List.of(true, false)) {
for (String name : List.of("votetogglereminders", "adminvotesynthetic")) {
try (Fixture f = new Fixture(true, name, aliases)) {
f.plugin.getVoteCommand().add(f.handler("ToggleReminders", "VotingPlugin.Commands.Vote.ToggleReminders"));
f.plugin.getAdminVoteCommand().add(f.handler("Synthetic", "VotingPlugin.Commands.AdminVote.Synthetic"));
f.loader.loadAliases();
PluginCommand root = f.owned.get("vote");
assertSame(root, f.map.getCommand(name));
assertInstanceOf(com.bencodez.votingplugin.commands.executers.CommandVote.class, root.getExecutor());
assertFalse(f.loader.getAliasCommandNames().contains(name));
assertTrue(f.map.dispatch(f.player, name + " GUI"));
assertTrue(f.map.dispatch(f.player, name + " Help"));
assertEquals(2, f.executed.get());
assertTrue(root.tabComplete(f.player, name, new String[] { "" }).contains("GUI"));
assertNotEquals("VotingPlugin.Commands.AdminVote.Synthetic", root.getPermission());
assertNotEquals("VotingPlugin.Commands.Vote.ToggleReminders", root.getPermission());
}
}
}
}
@Test
void disabledSyntheticAliasCannotUnregisterRenamedRoot() throws Exception {
try (Fixture f = new Fixture(true, "votetogglereminders", true)) {
var field = CommandLoader.class.getDeclaredField("disabledAliasCommandNames");
field.setAccessible(true);
@SuppressWarnings("unchecked") var disabled = (java.util.Set<String>) field.get(f.loader);
disabled.add("votetogglereminders");
disabled.add("votegui");
f.loader.loadAliases();
assertSame(f.owned.get("vote"), f.map.getCommand("votetogglereminders"));
assertSame(f.owned.get("vote"), f.map.getCommand("votingplugin:votetogglereminders"));
assertTrue(f.loader.isVoteCommandEnabled());
assertTrue(f.map.dispatch(f.player, "votetogglereminders Help"));
assertEquals(1, f.executed.get());
assertNull(f.map.getCommand("votegui"));
assertNull(f.map.getCommand("vgui"));
}
}
@Test
void foreignBareLabelKeepsItsOwnerAndGeneratedActionsUseTheVotingPluginNamespace() throws Exception {
try (Fixture f = new Fixture(true, "ballot", true)) {
Command foreign = f.foreign("ballot", "other");
f.loader.loadAliases();
assertSame(foreign, f.map.getCommand("ballot"));
assertSame(f.owned.get("vote"), f.map.getCommand("votingplugin:ballot"));
assertEquals("/votingplugin:ballot", f.plugin.getVoteCommandPrefix());
f.loader.processSlotClick(f.player, mock(VotingPluginUser.class), "help");
verify(f.player).performCommand("votingplugin:ballot help");
assertFalse(f.loader.isVotingPluginCommand(f.player, "ballot"));
assertTrue(f.loader.isVotingPluginCommand(f.player, "votingplugin:ballot"));
assertTrue(f.map.dispatch(f.player, "votingplugin:ballot GUI"));
assertEquals(1, f.executed.get());
}
}
@Test
void renamedFallbackPublishesThroughForwardingMapWithoutReplacingForeignCommand() throws Exception {
try (Fixture f = new Fixture(true, "ballot", true)) {
f.map.useForwardingStorage();
Command foreign = f.foreign("ballot", "other");
f.loader.loadAliases();
assertSame(foreign, f.map.getCommand("ballot"));
assertSame(f.owned.get("vote"), f.map.getCommand("votingplugin:ballot"));
assertTrue(f.plugin.isVoteCommandEnabled());
assertEquals("/votingplugin:ballot", f.plugin.getVoteCommandPrefix());
assertTrue(f.map.dispatch(f.player, "votingplugin:ballot GUI"));
assertEquals(1, f.executed.get());
}
}
@Test
void occupiedNamespaceCannotBeOverwrittenEvenWhenBareLabelIsFree() throws Exception {
try (Fixture f = new Fixture(true, "ballot", true)) {
Command foreign = f.foreign("ballot", "other");
f.map.known().remove("ballot");
f.map.known().put("votingplugin:ballot", foreign);
f.loader.loadAliases();
assertSame(foreign, f.map.getCommand("votingplugin:ballot"));
assertNull(f.map.getCommand("ballot"));
assertSame(f.owned.get("vote"), f.map.getCommand("vote"));
assertEquals("/vote", f.plugin.getVoteCommandPrefix());
}
}
@Test
void foreignBareAliasCannotBeReplacedByBukkitsPrimaryCommandRegistration() throws Exception {
try (Fixture f = new Fixture(true, "ballot", true)) {
Command foreign = f.foreign("poll", "other");
f.map.known().put("ballot", foreign);
f.loader.loadAliases();
assertSame(foreign, f.map.getCommand("ballot"));
assertSame(foreign, f.map.getCommand("poll"));
assertSame(f.owned.get("vote"), f.map.getCommand("votingplugin:ballot"));
assertEquals("/votingplugin:ballot", f.plugin.getVoteCommandPrefix());
assertTrue(f.map.dispatch(f.player, "votingplugin:ballot GUI"));
assertEquals(1, f.executed.get());
}
}
@Test
void invalidNamesAndOwnedAdminOrPlayerAliasNamesFallBackWithoutChangingThoseCommands() throws Exception {
for (String name : List.of("", "/ballot", "ballot GUI", "other:ballot", "bad\nname", "av", "adminvote", "avhelp", "fakevote", "votegui", "vgui")) {
try (Fixture f = new Fixture(true, name, true)) {
f.loader.loadAliases();
assertEquals("/vote", f.plugin.getVoteCommandPrefix(), name);
assertSame(f.owned.get("adminvote"), f.map.getCommand("adminvote"));
assertSame(f.owned.get("av"), f.map.getCommand("av"));
assertSame(f.owned.get("adminvotehelp"), f.map.getCommand("avhelp"));
assertSame(f.owned.get("votegui"), f.map.getCommand("vgui"));
}
}
}
@Test
void disablingRestoresOtherPluginsFallbackAndRemovesDisplacedOwnedAliasObjects() throws Exception {
try (Fixture f = new Fixture(false, "vote", true)) {
Command foreignVote = f.foreign("vote", "other");
Command foreignGui = f.foreign("votegui", "other");
// Simulate both canonical labels being replaced, leaving only vgui.
f.map.known().put("votegui", foreignGui);
f.map.known().put("votingplugin:votegui", foreignGui);
f.loader.loadAliases();
assertSame(foreignVote, f.map.getCommand("vote"));
assertSame(foreignGui, f.map.getCommand("votegui"));
assertSame(foreignGui, f.map.getCommand("votingplugin:votegui"));
assertNull(f.map.getCommand("vgui"));
assertNull(f.map.getCommand("votingplugin:vgui"));
assertNull(f.map.getCommand("votingplugin:vote"));
}
}
@Test
void aliasToggleRemovesPlayerAndAdminStandaloneAliasesButPreservesRenamedRootAndAdminRoots() throws Exception {
try (Fixture f = new Fixture(true, "ballot", false)) {
Command foreign = f.foreign("votegui", "other");
f.loader.loadAliases();
assertSame(foreign, f.map.getCommand("votegui"));
assertNull(f.map.getCommand("votingplugin:votegui"));
assertNull(f.map.getCommand("vgui"));
assertNull(f.map.getCommand("adminvotehelp"));
assertNull(f.map.getCommand("avhelp"));
assertSame(f.owned.get("vote"), f.map.getCommand("ballot"));
assertSame(f.owned.get("adminvote"), f.map.getCommand("adminvote"));
assertSame(f.owned.get("av"), f.map.getCommand("av"));
}
}
@Test
void reloadCannotChangeCapturedRootToggleAliasesHelpOrNavigation() throws Exception {
try (Fixture f = new Fixture(true, "ballot", true)) {
f.loader.loadAliases();
when(f.config.isVoteCommandEnabled()).thenReturn(false);
when(f.config.getVoteCommandName()).thenReturn("newroot");
when(f.config.isLoadCommandAliases()).thenReturn(false);
f.loader.loadAliases();
assertTrue(f.plugin.isVoteCommandEnabled());
assertEquals("/ballot", f.plugin.getVoteCommandPrefix());
assertSame(f.owned.get("vote"), f.map.getCommand("ballot"));
assertNull(f.map.getCommand("newroot"));
assertSame(f.owned.get("votegui"), f.map.getCommand("vgui"));
f.loader.processSlotClick(f.player, mock(VotingPluginUser.class), "help");
verify(f.player).performCommand("ballot help");
VoteHelp help = new VoteHelp(f.plugin, f.player, 1);
assertTrue(help.helpText(f.player).stream().allMatch(line -> line.getClickEvent().getValue().startsWith("/ballot ")));
assertEquals(List.of("GUI", "Help"), f.owned.get("vote").tabComplete(f.player, "ballot", new String[] { "" }));
}
}
@Test
void disabledStartupCannotBecomeRegisteredThroughReload() throws Exception {
try (Fixture f = new Fixture(false, "vote", true)) {
f.loader.loadAliases();
when(f.config.isVoteCommandEnabled()).thenReturn(true);
when(f.config.getVoteCommandName()).thenReturn("ballot");
f.loader.loadAliases();
assertFalse(f.plugin.isVoteCommandEnabled());
assertNull(f.map.getCommand("vote"));
assertNull(f.map.getCommand("ballot"));
assertNull(f.map.getCommand("vgui"));
}
}
@Test
void clickableAndLegacyHelpAndExistingInvalidHintUseActiveRoot() throws Exception {
try (Fixture f = new Fixture(true, "ballot", true)) {
f.loader.loadAliases();
VoteHelp help = new VoteHelp(f.plugin, f.player, 1);
assertEquals(2, help.helpText(f.player).size());
assertEquals(2, help.helpTextLegacy(f.player).size());
assertTrue(help.helpText(f.player).stream().allMatch(line -> line.getClickEvent().getValue().startsWith("/ballot ")));
assertTrue(help.helpTextLegacy(f.player).stream().allMatch(line -> line.getClickEvent().getValue().startsWith("/ballot ")));
assertEquals("see /ballot help!", f.plugin.formatVoteCommandMessage("see /vote help!"));
assertEquals("see /ballot help!", f.plugin.formatVoteCommandMessage("see %votecommand% help!"));
assertEquals("/votechallenge /adminvote", f.plugin.formatVoteCommandMessage("/votechallenge /adminvote"));
f.map.dispatch(f.player, "ballot unknown");
verify(f.player).sendMessage("see /ballot help!");
f.map.dispatch(f.player, "vgui unknown");
verify(f.player, times(2)).sendMessage("see /ballot help!");
}
}
@Test
void urlDialogBackButtonUsesCapturedRootAndDoesNothingWhenPlayerCommandsAreDisabled() throws Exception {
for (boolean enabled : List.of(true, false)) {
try (Fixture f = new Fixture(enabled, "ballot", true)) {
f.loader.loadAliases();
UniDialogService service = mock(UniDialogService.class);
MultiActionDialogBuilder dialog = mock(MultiActionDialogBuilder.class, RETURNS_SELF);
GUI gui = mock(GUI.class);
VoteSiteManager sites = mock(VoteSiteManager.class);
when(f.plugin.getDialogService()).thenReturn(service);
when(service.multiAction(f.player)).thenReturn(dialog);
when(f.plugin.getGui()).thenReturn(gui);
when(f.plugin.getVoteSiteManager()).thenReturn(sites);
when(sites.getVoteSitesEnabled()).thenReturn(new ArrayList<>());
when(gui.isChestVoteURLBackButton()).thenReturn(true);
new VoteURL(f.plugin, f.player, mock(VotingPluginUser.class), true).onDialog(f.player);
@SuppressWarnings("unchecked")
ArgumentCaptor<Consumer<DialogPayload>> callback = ArgumentCaptor.forClass(Consumer.class);
verify(dialog).button(eq("&eBack"), eq("&7Return to previous menu"), callback.capture());
callback.getValue().accept(null);
if (enabled) verify(f.player).performCommand("ballot gui");
else verify(f.player, never()).performCommand(anyString());
}
}
}
/** Exposes the same public method provided by CraftServer, without CraftBukkit packages. */
public interface CommandMapAccess { CommandMap getCommandMap(); }
private static final class TestCommandMap extends SimpleCommandMap {
TestCommandMap(Server server) { super(server); }
Map<String, Command> known() { return knownCommands; }
void useForwardingStorage() throws Exception {
var forwarding = new ForwardingStorage();
forwarding.putAll(knownCommands);
var field = SimpleCommandMap.class.getDeclaredField("knownCommands");
field.setAccessible(true);
field.set(this, forwarding);
}
}
// Paper's bridge overrides the basic map operations but inherits HashMap's
// putIfAbsent, which writes backing storage instead of the command dispatcher.
private static final class ForwardingStorage extends HashMap<String, Command> {
private final Map<String, Command> dispatcher = new java.util.LinkedHashMap<>();
@Override public Command get(Object key) { return dispatcher.get(key); }
@Override public Command put(String key, Command value) { return dispatcher.put(key, value); }
@Override public void putAll(Map<? extends String, ? extends Command> values) { values.forEach(this::put); }
@Override public Command remove(Object key) { return dispatcher.remove(key); }
@Override public boolean remove(Object key, Object value) { return dispatcher.remove(key, value); }
@Override public boolean containsKey(Object key) { return dispatcher.containsKey(key); }
@Override public boolean containsValue(Object value) { return dispatcher.containsValue(value); }
@Override public int size() { return dispatcher.size(); }
@Override public boolean isEmpty() { return dispatcher.isEmpty(); }
@Override public void clear() { dispatcher.clear(); }
@Override public java.util.Set<Map.Entry<String, Command>> entrySet() { return dispatcher.entrySet(); }
@Override public java.util.Set<String> keySet() { return dispatcher.keySet(); }
@Override public Collection<Command> values() { return dispatcher.values(); }
}
private static final class Fixture implements AutoCloseable {
final VotingPluginMain previousPlugin = VotingPluginMain.plugin;
final VotingPluginMain plugin = mock(VotingPluginMain.class);
final Config config = mock(Config.class);
final Player player = mock(Player.class);
final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
final TestCommandMap map;
final Map<String, PluginCommand> owned = new HashMap<>();
final CommandLoader loader;
final AtomicInteger executed = new AtomicInteger();
Fixture(boolean enabled, String name, boolean aliases) throws Exception {
VotingPluginMain.plugin = plugin;
try {
Server server = mock(Server.class, withSettings().extraInterfaces(CommandMapAccess.class));
PluginManager manager = mock(PluginManager.class);
Logger logger = Logger.getLogger("ConfigurableVoteCommandTest");
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getPluginManager).thenReturn(manager);
bukkit.when(Bukkit::getLogger).thenReturn(logger);
bukkit.when(Bukkit::getVersion).thenReturn("Mock (MC: 1.21.9)");
bukkit.when(Bukkit::getBukkitVersion).thenReturn("1.21.9-R0.1-SNAPSHOT");
map = new TestCommandMap(server);
when(((CommandMapAccess) server).getCommandMap()).thenReturn(map);
when(plugin.getServer()).thenReturn(server);
when(plugin.getConfigFile()).thenReturn(config);
when(plugin.getLogger()).thenReturn(logger);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("VotingPlugin");
PluginDescriptionFile description = new PluginDescriptionFile(getClass().getResourceAsStream("/plugin.yml"));
when(plugin.getDescription()).thenReturn(description);
AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class);
when(plugin.getOptions()).thenReturn(options);
when(options.isMultiplePermissionChecks()).thenReturn(true);
when(config.isVoteCommandEnabled()).thenReturn(enabled);
when(config.getVoteCommandName()).thenReturn(name);
when(config.isLoadCommandAliases()).thenReturn(aliases);
when(config.getFormatCommandsVoteHelpHoverColor()).thenReturn("aqua");
when(config.getFormatCommandsVoteHelpLine()).thenReturn("%Command% : %HelpMessage%");
when(config.getFormatInvalidCommandVote()).thenReturn("see /vote help!");
BukkitScheduler scheduler = mock(BukkitScheduler.class);
when(plugin.getBukkitScheduler()).thenReturn(scheduler);
doAnswer(call -> { ((Runnable) call.getArgument(1)).run(); return null; })
.when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class));
ArrayList<CommandHandler> playerHandlers = new ArrayList<>();
playerHandlers.add(handler("GUI", "VotingPlugin.Commands.Vote.GUI"));
playerHandlers.add(handler("Help", "VotingPlugin.Commands.Vote.Help"));
when(plugin.getVoteCommand()).thenReturn(playerHandlers);
var adminHandlers = new ArrayList<>(List.of(handler("Help", "VotingPlugin.Commands.AdminVote.Help")));
when(plugin.getAdminVoteCommand()).thenReturn(adminHandlers);
when(player.hasPermission(anyString())).thenReturn(true);
for (var entry : description.getCommands().entrySet()) {
PluginCommand command = command(entry.getKey(), plugin);
Object declaredAliases = entry.getValue().get("aliases");
if (declaredAliases instanceof String alias) command.setAliases(List.of(alias));
else if (declaredAliases instanceof Collection<?> values)
command.setAliases(values.stream().map(String::valueOf).toList());
owned.put(entry.getKey(), command);
map.register("votingplugin", command);
}
when(plugin.getCommand(anyString())).thenAnswer(call -> {
String key = ((String) call.getArgument(0)).toLowerCase(java.util.Locale.ROOT);
Command command = map.getCommand(key);
if (!(command instanceof PluginCommand result) || result.getPlugin() != plugin)
command = map.getCommand("votingplugin:" + key);
return command instanceof PluginCommand result && result.getPlugin() == plugin ? result : null;
});
loader = new CommandLoader(plugin);
var field = VotingPluginMain.class.getDeclaredField("commandLoader");
field.setAccessible(true);
field.set(plugin, loader);
when(plugin.getCommandLoader()).thenReturn(loader);
when(plugin.getVoteCommandPrefix()).thenCallRealMethod();
when(plugin.isVoteCommandEnabled()).thenCallRealMethod();
when(plugin.formatVoteCommandMessage(anyString())).thenCallRealMethod();
} catch (Exception | Error failure) {
bukkit.close();
VotingPluginMain.plugin = previousPlugin;
throw failure;
}
}
CommandHandler handler(String argument, String permission) {
return new CommandHandler(plugin, new String[] { argument }, permission, "Open menu") {
@Override public void execute(CommandSender sender, String[] args) { executed.incrementAndGet(); }
};
}
Command foreign(String label, String namespace) throws Exception {
Plugin owner = mock(Plugin.class);
when(owner.getName()).thenReturn("OtherPlugin");
PluginCommand command = command(label, owner);
map.register(namespace, command);
return command;
}
static PluginCommand command(String name, Plugin owner) throws Exception {
Constructor<PluginCommand> constructor = PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
constructor.setAccessible(true);
return ReflectiveAccess.newInstance(constructor, new Object[]{name, owner});
}
@Override public void close() {
bukkit.close();
VotingPluginMain.plugin = previousPlugin;
}
}
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.commands;
package com.bencodez.votingplugin.tests.commands;
import com.bencodez.votingplugin.commands.CommandLoader;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -133,20 +135,25 @@ class OfflineVoteClearCommandRegistrationTest {
}
}
private static final class TestLoader extends CommandLoader {
private final AtomicReference<java.util.function.BooleanSupplier> mutation;
private static final class TestLoader {
private final CommandLoader delegate;
TestLoader(VotingPluginMain plugin, AtomicReference<java.util.function.BooleanSupplier> mutation) {
super(plugin);
this.mutation = mutation;
}
TestLoader(VotingPluginMain plugin, AtomicReference<java.util.function.BooleanSupplier> mutation) {
delegate = org.mockito.Mockito.mock(CommandLoader.class, org.mockito.Mockito.withSettings()
.useConstructor(plugin).defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS));
CommandLoaderAccess.runForCommandSender(org.mockito.Mockito.doAnswer(invocation -> {
invocation.getArgument(1, Runnable.class).run();
return null;
}).when(delegate), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
CommandLoaderAccess.runConfirmedBulkStorageMutation(org.mockito.Mockito.doAnswer(invocation -> {
mutation.set(invocation.getArgument(1));
return null;
}).when(delegate), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any());
}
@Override
void runForCommandSender(CommandSender sender, Runnable task) { task.run(); }
@Override
void runConfirmedBulkStorageMutation(CommandSender sender, java.util.function.BooleanSupplier work, Runnable success) {
mutation.set(work);
}
}
void registerOfflineVoteClearCommands(String permission) {
CommandLoaderAccess.registerOfflineVoteClearCommands(delegate, permission);
}
}
}
@@ -0,0 +1,16 @@
package com.bencodez.votingplugin.tests.commands.gui.admin;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.commands.gui.admin.*;
import com.bencodez.votingplugin.commands.gui.admin.AdminVotePerms.*;
import java.util.ArrayList;
import org.bukkit.command.CommandSender;
import com.bencodez.advancedcore.api.command.CommandHandler;
/** Typed test-only access to package-private AdminVotePerms seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class AdminVotePermsAccess {
private AdminVotePermsAccess() { }
public static ArrayList<String> additionalPermissions(CommandHandler handle) { return (ArrayList<String>) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.admin.AdminVotePerms", null, "additionalPermissions", new String[]{"CommandHandler"}, new Object[]{handle}); }
public static boolean hasEffectivePermission(CommandSender sender, String permission) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.admin.AdminVotePerms", null, "hasEffectivePermission", new String[]{"CommandSender", "String"}, new Object[]{sender, permission}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.commands.gui.admin;
package com.bencodez.votingplugin.tests.commands.gui.admin;
import com.bencodez.votingplugin.commands.gui.admin.AdminVotePerms;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -37,14 +39,14 @@ class AdminVotePermsTest {
"VotingPlugin.Commands.AdminVote.AddPoints.All");
assertEquals(Collections.singletonList("VotingPlugin.Commands.AdminVote.SetPoints.All"),
AdminVotePerms.additionalPermissions(setPoints));
AdminVotePermsAccess.additionalPermissions(setPoints));
assertEquals(Collections.singletonList("VotingPlugin.Commands.AdminVote.AddPoints.All"),
AdminVotePerms.additionalPermissions(addPoints));
AdminVotePermsAccess.additionalPermissions(addPoints));
}
@Test
void ordinaryCommandsDoNotInventAdditionalPermissions() {
assertEquals(Collections.emptyList(), AdminVotePerms.additionalPermissions(mock(CommandHandler.class)));
assertEquals(Collections.emptyList(), AdminVotePermsAccess.additionalPermissions(mock(CommandHandler.class)));
}
@Test
@@ -125,7 +127,7 @@ class AdminVotePermsTest {
void editorPermissionStatusIncludesAdminOverride() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission(AdminAuthorization.ADMIN_PERMISSION)).thenReturn(true);
assertTrue(AdminVotePerms.hasEffectivePermission(sender, "VotingPlugin.Commands.AdminVote.Edit.Config"));
assertTrue(AdminVotePermsAccess.hasEffectivePermission(sender, "VotingPlugin.Commands.AdminVote.Edit.Config"));
}
@Test
@@ -215,6 +217,7 @@ class AdminVotePermsTest {
when(plugin.getVoteCommand()).thenReturn(new ArrayList<>(vote));
when(plugin.getAdminVoteCommand()).thenReturn(new ArrayList<>(admin));
when(plugin.getConfigFile()).thenReturn(config);
when(plugin.getVoteCommandPrefix()).thenReturn("/vote");
when(plugin.getOptions()).thenReturn(options);
when(options.isMultiplePermissionChecks()).thenReturn(multiplePermissions);
when(config.getFormatPageSize()).thenReturn(pageSize);
@@ -0,0 +1,16 @@
package com.bencodez.votingplugin.tests.commands.gui.player;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.commands.gui.player.*;
import com.bencodez.votingplugin.commands.gui.player.VoteGUI.*;
import org.bukkit.entity.Player;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.user.VotingPluginUser;
/** Typed test-only access to package-private VoteGUI seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class VoteGUIAccess {
private VoteGUIAccess() { }
public static String getRequiredPermission(VotingPluginMain plugin, Player player, VotingPluginUser viewedUser) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteGUI", null, "getRequiredPermission", new String[]{"VotingPluginMain", "Player", "VotingPluginUser"}, new Object[]{plugin, player, viewedUser}); }
public static boolean isAuthorized(VotingPluginMain plugin, Player player, VotingPluginUser viewedUser) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteGUI", null, "isAuthorized", new String[]{"VotingPluginMain", "Player", "VotingPluginUser"}, new Object[]{plugin, player, viewedUser}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.commands.gui.player;
package com.bencodez.votingplugin.tests.commands.gui.player;
import com.bencodez.votingplugin.commands.gui.player.VoteGUI;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -36,7 +38,7 @@ class VoteGUIPermissionTest {
assertNotEquals(player.getUniqueId().toString(), viewedUser.getUUID());
assertEquals("VotingPlugin.Commands.Vote.GUI",
VoteGUI.getRequiredPermission(plugin, player, viewedUser));
VoteGUIAccess.getRequiredPermission(plugin, player, viewedUser));
}
@Test
@@ -53,7 +55,7 @@ class VoteGUIPermissionTest {
when(viewedUser.getUUID()).thenReturn(null);
assertEquals("VotingPlugin.Commands.Vote.GUI.Other",
VoteGUI.getRequiredPermission(plugin, player, viewedUser));
VoteGUIAccess.getRequiredPermission(plugin, player, viewedUser));
}
@Test
@@ -70,10 +72,10 @@ class VoteGUIPermissionTest {
when(viewedUser.getUUID()).thenReturn("33333333-3333-3333-3333-333333333333");
assertEquals("VotingPlugin.Commands.Vote.GUI.Other",
VoteGUI.getRequiredPermission(plugin, player, viewedUser));
VoteGUIAccess.getRequiredPermission(plugin, player, viewedUser));
when(player.hasPermission("VotingPlugin.Commands.Vote.GUI.Other")).thenReturn(false, true);
assertFalse(VoteGUI.isAuthorized(plugin, player, viewedUser));
assertTrue(VoteGUI.isAuthorized(plugin, player, viewedUser));
assertFalse(VoteGUIAccess.isAuthorized(plugin, player, viewedUser));
assertTrue(VoteGUIAccess.isAuthorized(plugin, player, viewedUser));
}
@Test
@@ -89,8 +91,8 @@ class VoteGUIPermissionTest {
when(viewedUser.getUUID()).thenReturn("target");
when(player.hasPermission("VotingPlugin.Commands.Vote.GUI.Other")).thenReturn(true, false);
assertTrue(VoteGUI.isAuthorized(plugin, player, viewedUser), "dialog may initially open");
assertFalse(VoteGUI.isAuthorized(plugin, player, viewedUser),
assertTrue(VoteGUIAccess.isAuthorized(plugin, player, viewedUser), "dialog may initially open");
assertFalse(VoteGUIAccess.isAuthorized(plugin, player, viewedUser),
"dialog action must observe a permission revoked after opening");
}
}
@@ -0,0 +1,28 @@
package com.bencodez.votingplugin.tests.commands.gui.player;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.commands.gui.player.*;
import java.util.function.Predicate;
import com.bencodez.votingplugin.voteshop.shop.VoteShopCategory;
import com.bencodez.votingplugin.voteshop.shop.VoteShopCategoryButton;
import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition;
import com.bencodez.votingplugin.voteshop.shop.VoteShopItem;
/** Test-only forwarding access to the non-public production VoteShopCategoryAuthorization. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class VoteShopCategoryAuthorizationAccess implements ReflectiveAccess.View {
private final Object delegate;
private VoteShopCategoryAuthorizationAccess(Object delegate) { this.delegate = delegate; }
public static VoteShopCategoryAuthorizationAccess view(Object delegate) { return delegate == null ? null : new VoteShopCategoryAuthorizationAccess(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof VoteShopCategoryAuthorizationAccess value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public static VoteShopCategoryAuthorizationAccess mock() { return view(org.mockito.Mockito.mock(ReflectiveAccess.type("com.bencodez.votingplugin.commands.gui.player.VoteShopCategoryAuthorization"))); }
public VoteShopCategoryAuthorizationAccess verify() { return view(org.mockito.Mockito.verify(delegate)); }
public VoteShopCategoryAuthorizationAccess verify(org.mockito.verification.VerificationMode mode) { return view(org.mockito.Mockito.verify(delegate, mode)); }
public VoteShopCategoryAuthorizationAccess stub(org.mockito.stubbing.Stubber stubber) { return view(stubber.when(delegate)); }
public static boolean mayDisplay(VoteShopCategoryButton button, Predicate<String> permissionCheck) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteShopCategoryAuthorization", null, "mayDisplay", new String[]{"VoteShopCategoryButton", "Predicate"}, new Object[]{button, permissionCheck}); }
public static boolean authorized(VoteShopCategoryButton button, Predicate<String> permissionCheck) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteShopCategoryAuthorization", null, "authorized", new String[]{"VoteShopCategoryButton", "Predicate"}, new Object[]{button, permissionCheck}); }
public static boolean canOpen(VoteShopDefinition definition, String targetCategory, Predicate<String> permissionCheck) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteShopCategoryAuthorization", null, "canOpen", new String[]{"VoteShopDefinition", "String", "Predicate"}, new Object[]{definition, targetCategory, permissionCheck}); }
public static boolean canPurchase(VoteShopDefinition definition, VoteShopCategory category, VoteShopItem item, Predicate<String> permissionCheck) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteShopCategoryAuthorization", null, "canPurchase", new String[]{"VoteShopDefinition", "VoteShopCategory", "VoteShopItem", "Predicate"}, new Object[]{definition, category, item, permissionCheck}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.commands.gui.player;
package com.bencodez.votingplugin.tests.commands.gui.player;
import com.bencodez.votingplugin.tests.commands.gui.player.VoteShopCategoryAuthorizationAccess;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -20,17 +22,17 @@ class VoteShopCategoryAuthorizationTest {
VoteShopCategoryButton button = button("restricted", "shop.category", false);
VoteShopDefinition definition = definition(button);
assertTrue(VoteShopCategoryAuthorization.mayDisplay(button, permission -> false));
assertFalse(VoteShopCategoryAuthorization.authorized(button, permission -> false));
assertFalse(VoteShopCategoryAuthorization.canOpen(definition, "restricted", permission -> false));
assertTrue(VoteShopCategoryAuthorizationAccess.mayDisplay(button, permission -> false));
assertFalse(VoteShopCategoryAuthorizationAccess.authorized(button, permission -> false));
assertFalse(VoteShopCategoryAuthorizationAccess.canOpen(definition, "restricted", permission -> false));
}
@Test
void hideFlagControlsVisibilityWithoutChangingAuthorization() {
VoteShopCategoryButton hidden = button("restricted", "shop.category", true);
assertFalse(VoteShopCategoryAuthorization.mayDisplay(hidden, permission -> false));
assertTrue(VoteShopCategoryAuthorization.mayDisplay(hidden, permission -> true));
assertFalse(VoteShopCategoryAuthorizationAccess.mayDisplay(hidden, permission -> false));
assertTrue(VoteShopCategoryAuthorizationAccess.mayDisplay(hidden, permission -> true));
}
@Test
@@ -46,11 +48,11 @@ class VoteShopCategoryAuthorizationTest {
child.setId("child");
definition.getCategories().put("child", child);
assertFalse(VoteShopCategoryAuthorization.canOpen(definition, "child",
assertFalse(VoteShopCategoryAuthorizationAccess.canOpen(definition, "child",
Set.of("shop.child")::contains));
assertFalse(VoteShopCategoryAuthorization.canOpen(definition, "child",
assertFalse(VoteShopCategoryAuthorizationAccess.canOpen(definition, "child",
Set.of("shop.parent")::contains));
assertTrue(VoteShopCategoryAuthorization.canOpen(definition, "child",
assertTrue(VoteShopCategoryAuthorizationAccess.canOpen(definition, "child",
Set.of("shop.parent", "shop.child")::contains));
}
@@ -63,14 +65,14 @@ class VoteShopCategoryAuthorizationTest {
category.getEntries().put("item", item);
AtomicBoolean permitted = new AtomicBoolean(true);
assertTrue(VoteShopCategoryAuthorization.canPurchase(definition, category, item,
assertTrue(VoteShopCategoryAuthorizationAccess.canPurchase(definition, category, item,
permission -> permitted.get()));
permitted.set(false);
assertFalse(VoteShopCategoryAuthorization.canPurchase(definition, category, item,
assertFalse(VoteShopCategoryAuthorizationAccess.canPurchase(definition, category, item,
permission -> permitted.get()));
VoteShopItem mainItem = item("main");
definition.getMainEntries().put("main", mainItem);
assertTrue(VoteShopCategoryAuthorization.canPurchase(definition, null, mainItem, permission -> false));
assertTrue(VoteShopCategoryAuthorizationAccess.canPurchase(definition, null, mainItem, permission -> false));
}
@Test
@@ -82,7 +84,7 @@ class VoteShopCategoryAuthorizationTest {
definition.getCategories().remove("restricted");
assertFalse(VoteShopCategoryAuthorization.canPurchase(definition, staleCategory, item,
assertFalse(VoteShopCategoryAuthorizationAccess.canPurchase(definition, staleCategory, item,
permission -> true));
}
@@ -97,7 +99,7 @@ class VoteShopCategoryAuthorizationTest {
replacement.getEntries().put("item", item("item"));
definition.getCategories().put("restricted", replacement);
assertFalse(VoteShopCategoryAuthorization.canPurchase(definition, staleCategory, staleItem,
assertFalse(VoteShopCategoryAuthorizationAccess.canPurchase(definition, staleCategory, staleItem,
permission -> true));
}
@@ -0,0 +1,12 @@
package com.bencodez.votingplugin.tests.commands.gui.player;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.commands.gui.player.*;
import com.bencodez.votingplugin.commands.gui.player.VoteShopConfirm.*;
/** Typed test-only access to package-private VoteShopConfirm seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class VoteShopConfirmAccess {
private VoteShopConfirmAccess() { }
public static boolean beginPurchase(VoteShopConfirm target) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.commands.gui.player.VoteShopConfirm", target, "beginPurchase", new String[]{}, new Object[]{}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.commands.gui.player;
package com.bencodez.votingplugin.tests.commands.gui.player;
import com.bencodez.votingplugin.commands.gui.player.VoteShopConfirm;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -10,7 +12,7 @@ class VoteShopConfirmTest {
void confirmationCanSubmitOnlyOnePurchase() {
VoteShopConfirm confirmation = new VoteShopConfirm(null, null, null, null, null);
assertTrue(confirmation.beginPurchase());
assertFalse(confirmation.beginPurchase());
assertTrue(VoteShopConfirmAccess.beginPurchase(confirmation));
assertFalse(VoteShopConfirmAccess.beginPurchase(confirmation));
}
}
@@ -0,0 +1,61 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import com.bencodez.votingplugin.control.BackendConfigurationService.*;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.channels.FileChannel;
import java.util.List;
import java.util.Map;
/** Typed test-only access to package-private BackendConfigurationService seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendConfigurationServiceAccess {
public static final int MAX_REWARD_DIRECTORY_ENTRIES = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.control.BackendConfigurationService", "MAX_REWARD_DIRECTORY_ENTRIES");
private BackendConfigurationServiceAccess() { }
public static BackendConfigurationService create(Path dataDirectory, ReloadAction reload) { return (BackendConfigurationService) ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendConfigurationService", new String[]{"Path", "ReloadAction"}, new Object[]{dataDirectory, reload}); }
public static BackendConfigurationService create(Path dataDirectory, ApplyAction reload) { return (BackendConfigurationService) ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendConfigurationService", new String[]{"Path", "ApplyAction"}, new Object[]{dataDirectory, reload}); }
public static BackendConfigurationService create(Path dataDirectory, ApplyAction reload, BackendConfigurationServiceAccess.MoveAction mover) { return (BackendConfigurationService) ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendConfigurationService", new String[]{"Path", "ApplyAction", "MoveAction"}, new Object[]{dataDirectory, reload, mover}); }
public static boolean supportsNamedRewardFiles(BackendConfigurationService target) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "supportsNamedRewardFiles", new String[]{}, new Object[]{}); }
public static boolean supportsNamedRewardFiles(BackendConfigurationService target, BackendConfigurationServiceAccess.DirectoryStagingSupport staging) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "supportsNamedRewardFiles", new String[]{"DirectoryStagingSupport"}, new Object[]{staging}); }
public static boolean supportsNamedRewardFiles(Path directory) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "supportsNamedRewardFiles", new String[]{"Path"}, new Object[]{directory}); }
public static boolean supportsNamedRewardFiles(Path directory, BackendConfigurationServiceAccess.DirectoryForcer force) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "supportsNamedRewardFiles", new String[]{"Path", "DirectoryForcer"}, new Object[]{directory, force}); }
public static boolean supportsNamedRewardFiles(Path directory, BackendConfigurationServiceAccess.DirectoryForcer force, BackendConfigurationServiceAccess.DirectoryStagingSupport staging) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "supportsNamedRewardFiles", new String[]{"Path", "DirectoryForcer", "DirectoryStagingSupport"}, new Object[]{directory, force, staging}); }
public static List<String> rewardFileInventory(Path dataDirectory) throws IOException { return (List<String>) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "rewardFileInventory", new String[]{"Path"}, new Object[]{dataDirectory}); }
public static List<String> rewardFileInventory(Path dataDirectory, BackendConfigurationServiceAccess.RewardDirectoryAction<List<String>> scan) throws IOException { return (List<String>) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "rewardFileInventory", new String[]{"Path", "RewardDirectoryAction"}, new Object[]{dataDirectory, scan}); }
public static String readRaw(java.nio.file.SecureDirectoryStream<Path> directory, String name, boolean allowMissing) throws IOException { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "readRaw", new String[]{"SecureDirectoryStream", "String", "boolean"}, new Object[]{directory, name, allowMissing}); }
public static boolean namedRewardCannotBeConfirmed(IOException failure) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "namedRewardCannotBeConfirmed", new String[]{"IOException"}, new Object[]{failure}); }
public static <T> T retryRead(BackendConfigurationServiceAccess.ReadAction<T> read) throws IOException { return (T) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "retryRead", new String[]{"ReadAction"}, new Object[]{read}); }
public static String proposedQuickSetupRevision(BackendConfigurationService target, String preset, QuickPreview preview) throws IOException { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "proposedQuickSetupRevision", new String[]{"String", "QuickPreview"}, new Object[]{preset, preview}); }
public static String currentQuickSetupRevision(BackendConfigurationService target, String preset) throws IOException { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "currentQuickSetupRevision", new String[]{"String"}, new Object[]{preset}); }
public static String currentQuickSetupRevision(BackendConfigurationService target, String preset, Map<String, String> options) throws IOException { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "currentQuickSetupRevision", new String[]{"String", "Map"}, new Object[]{preset, options}); }
public static ApplyResult applyQuickSetup(BackendConfigurationService target, String preset, Map<String, String> options, String expectedRevision) throws IOException { return (ApplyResult) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "applyQuickSetup", new String[]{"String", "Map", "String"}, new Object[]{preset, options, expectedRevision}); }
public static ApplyResult applyQuickSetup(BackendConfigurationService target, String preset, Map<String, String> options, String expectedRevision, ApplyAction applyAction) throws IOException { return (ApplyResult) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", target, "applyQuickSetup", new String[]{"String", "Map", "String", "ApplyAction"}, new Object[]{preset, options, expectedRevision, applyAction}); }
public static List<String> sanitizeComments(List<String> comments, List<String> secretValues, boolean secretPath) { return (List<String>) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendConfigurationService", null, "sanitizeComments", new String[]{"List", "List", "boolean"}, new Object[]{comments, secretValues, secretPath}); }
@FunctionalInterface
public interface ReadAction<T> {
T run() throws IOException;
}
@FunctionalInterface
public interface DirectoryForcer {
void force(FileChannel channel) throws IOException;
}
@FunctionalInterface
public interface DirectoryStagingSupport {
boolean supports(java.nio.file.SecureDirectoryStream<Path> directory) throws IOException;
}
@FunctionalInterface
public interface RewardDirectoryAction<T> {
T run(java.nio.file.SecureDirectoryStream<Path> directory) throws IOException;
}
@FunctionalInterface
public interface MoveAction {
void move(Path source, Path target) throws IOException;
}
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.BackendConfigurationService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -37,7 +39,7 @@ class BackendConfigurationServiceTest {
@Test void transientReadFailuresAreRetried() throws Exception {
AtomicInteger attempts = new AtomicInteger();
String value = BackendConfigurationService.retryRead(() -> {
String value = BackendConfigurationServiceAccess.retryRead(() -> {
if (attempts.incrementAndGet() < 3) throw new IOException("configuration is being replaced");
return "loaded";
});
@@ -47,17 +49,17 @@ class BackendConfigurationServiceTest {
}
@Test void nullYamlCommentMetadataDoesNotBreakControlReads() {
List<String> sanitized = BackendConfigurationService.sanitizeComments(
List<String> sanitized = BackendConfigurationServiceAccess.sanitizeComments(
Arrays.asList(null, "# Token: generated-secret", "# ordinary owner note"),
List.of("generated-secret"), false);
assertEquals(Arrays.asList(null, "# Token: " + BackendConfigurationService.REDACTED,
"# ordinary owner note"), sanitized);
assertEquals(List.of(), BackendConfigurationService.sanitizeComments(null, List.of(), false));
assertEquals(List.of(), BackendConfigurationServiceAccess.sanitizeComments(null, List.of(), false));
}
@Test void malformedQuickReadInputRemainsAValidationFailure() {
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("vote-site", Map.of()));
assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("vote-site",
@@ -75,7 +77,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "Database:\n Password: keep-me\nFeature: false\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory, reloads::incrementAndGet);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, reloads::incrementAndGet);
BackendConfigurationService.Document read = service.read("Config.yml");
assertFalse(read.content().contains("keep-me"));
@@ -102,7 +104,7 @@ class BackendConfigurationServiceTest {
+ " Password: keep-me # database credential\n"
+ "Feature: false # toggle from Control\n"
+ "# End of owner notes\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
assertFalse(read.content().contains("keep-me"));
@@ -139,7 +141,7 @@ class BackendConfigurationServiceTest {
+ "Other: true # Authorization: Bearer old-token\n"
+ "Hook: true # WebhookURL: https://example.invalid/private hook\n"
+ "Quoted: true # DatabasePassword: \"two words\"\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("commented-secret"));
@@ -156,7 +158,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "# User: database-admin\n# DatabaseUsername=database-owner\nFeature: false\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("database-admin"));
@@ -167,7 +169,7 @@ class BackendConfigurationServiceTest {
@Test void redactsSecretsInCommentOnlyDocuments() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "# Password: header-secret\n# owner footer\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("header-secret"));
assertTrue(read.content().contains(BackendConfigurationService.REDACTED));
@@ -176,7 +178,7 @@ class BackendConfigurationServiceTest {
@Test void doesNotRewriteHashTextInsideBlockScalars() throws Exception {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "Message: |\n # Password: this is message text\nFeature: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
assertTrue(read.content().contains("# Password: this is message text"));
@@ -189,7 +191,7 @@ class BackendConfigurationServiceTest {
Files.writeString(config, "Password: true\n# DiscordWebhook.URL: https://dotted-secret.invalid/hook\n"
+ "DiscordWebhook:\n URL: '' # \"URL\": https://old-secret.invalid/hook\n"
+ "Feature: false # This is true when enabled\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("old-secret.invalid"));
@@ -202,7 +204,7 @@ class BackendConfigurationServiceTest {
Files.writeString(config, "Password: |\n alpha-secret-part\n beta-secret-part\n"
+ "# rotate alpha-secret-part soon\nFeature: false\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("alpha-secret-part"));
@@ -213,7 +215,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "# Password:\n# real-comment-secret\nFeature: false\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("real-comment-secret"));
@@ -223,7 +225,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "# Password: |\n# first-comment-secret\n# second-comment-secret\n#\n"
+ "# owner note\nFeature: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
assertFalse(read.content().contains("first-comment-secret"));
@@ -241,7 +243,7 @@ class BackendConfigurationServiceTest {
Files.writeString(config, "# Password: |\n# literal-comment-secret\n"
+ "# Token: >-\n# folded-comment-secret\n"
+ "# ApiKey: |2+\n# indented-comment-secret\nFeature: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
assertFalse(read.content().contains("literal-comment-secret"));
@@ -265,7 +267,7 @@ class BackendConfigurationServiceTest {
@Test void rejectsChangedRedactedCommentPlaceholders() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "# Password: comment-secret\nFeature: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
String changed = read.content().replace("Password: " + BackendConfigurationService.REDACTED,
"Password changed: " + BackendConfigurationService.REDACTED);
@@ -275,7 +277,7 @@ class BackendConfigurationServiceTest {
@Test void rejectsRemovedOrReplacedRedactedCommentPlaceholders() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "# Password: comment-secret\nFeature: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
String removed = read.content().lines().filter(line -> !line.contains(BackendConfigurationService.REDACTED))
.collect(java.util.stream.Collectors.joining("\n")) + "\n";
@@ -289,7 +291,7 @@ class BackendConfigurationServiceTest {
@Test void rejectsRemovingAKeyThatOwnsARedactedComment() throws Exception {
Files.writeString(directory.resolve("Config.yml"),
"Feature: false # Password: inline-comment-secret\nOther: true\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
assertTrue(read.content().contains(BackendConfigurationService.REDACTED));
@@ -299,7 +301,7 @@ class BackendConfigurationServiceTest {
@Test void comparesRedactedCommentOwnersRegardlessOfMappingOrder() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "Alpha:\n Enabled: true # Password: alpha-secret\n"
+ "Beta:\n Enabled: true # Token: beta-secret\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
String proposal = "Beta:\n Enabled: false # Token: " + BackendConfigurationService.REDACTED + "\n"
@@ -321,14 +323,14 @@ class BackendConfigurationServiceTest {
< BackendConfigurationService.MAX_CONTENT_BYTES);
Files.writeString(directory.resolve("Config.yml"), raw);
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IOException.class, () -> service.read("Config.yml"));
}
@Test void reportsCommentOnlyChangesInPreview() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "# original owner note\nFeature: false # old inline note\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("Config.yml");
BackendConfigurationService.Preview preview = service.preview("Config.yml", read.content()
@@ -342,7 +344,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "# \"Password\": quoted-comment-secret\nFeature: false\n");
BackendConfigurationService.Document read = new BackendConfigurationService(directory, () -> { })
BackendConfigurationService.Document read = BackendConfigurationServiceAccess.create(directory, () -> { })
.read("Config.yml");
assertFalse(read.content().contains("quoted-comment-secret"));
@@ -350,7 +352,7 @@ class BackendConfigurationServiceTest {
@Test void rejectsStaleInvalidAndUnmanagedWrites() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "Feature: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = service.read("Config.yml").revision();
Files.writeString(directory.resolve("Config.yml"), "Feature: manual\n");
assertThrows(BackendConfigurationService.StaleRevisionException.class,
@@ -365,7 +367,7 @@ class BackendConfigurationServiceTest {
Path externalConfig = outside.resolve("Config.yml");
Files.writeString(externalConfig, "Feature: external\n");
Files.createSymbolicLink(root.resolve("Config.yml"), externalConfig);
BackendConfigurationService service = new BackendConfigurationService(root, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(root, () -> { });
assertThrows(java.io.IOException.class, () -> service.read("Config.yml"));
Files.createDirectory(outside.resolve("sites"));
@@ -380,9 +382,9 @@ class BackendConfigurationServiceTest {
Path rewards = Files.createDirectories(directory.resolve("Rewards"));
Files.writeString(rewards.resolve("Daily.yml"), "Commands: []\n");
Files.writeString(rewards.resolve("Standard.yml"), "Messages: {}\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertEquals(List.of("Daily.yml", "Standard.yml"), BackendConfigurationService.rewardFileInventory(directory));
assertEquals(List.of("Daily.yml", "Standard.yml"), BackendConfigurationServiceAccess.rewardFileInventory(directory));
assertDoesNotThrow(() -> service.read("Rewards/Daily.yml"));
assertThrows(IllegalArgumentException.class, () -> service.read("Rewards/../Config.yml"));
assertThrows(IllegalArgumentException.class, () -> service.read("Rewards/%2e%2e%2fConfig.yml"));
@@ -394,38 +396,38 @@ class BackendConfigurationServiceTest {
}
@Test void onlyAnAbsentRewardsDirectoryProducesAnEmptyInventory() throws Exception {
assertEquals(List.of(), BackendConfigurationService.rewardFileInventory(directory));
assertThrows(IOException.class, () -> BackendConfigurationService.rewardFileInventory(directory.resolve("missing")));
assertEquals(List.of(), BackendConfigurationServiceAccess.rewardFileInventory(directory));
assertThrows(IOException.class, () -> BackendConfigurationServiceAccess.rewardFileInventory(directory.resolve("missing")));
Files.createDirectory(directory.resolve("Rewards"));
assertThrows(java.nio.file.NoSuchFileException.class,
() -> BackendConfigurationService.rewardFileInventory(directory,
() -> BackendConfigurationServiceAccess.rewardFileInventory(directory,
ignored -> { throw new java.nio.file.NoSuchFileException("disappeared-entry.yml"); }));
}
@Test void namedRewardCapabilityRequiresSecureDirectoryAndPrivateFileSupport() throws Exception {
assertTrue(BackendConfigurationService.supportsNamedRewardFiles(directory));
assertTrue(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory));
try (var entries = Files.list(directory)) {
assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith(".control-capability-")));
}
assertFalse(BackendConfigurationService.supportsNamedRewardFiles(directory,
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory,
ignored -> { throw new IOException("directory forcing is unavailable"); }));
Path outside = Files.createDirectory(directory.resolve("outside"));
Files.createSymbolicLink(directory.resolve("Rewards"), outside);
assertFalse(BackendConfigurationService.supportsNamedRewardFiles(directory));
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory));
Files.delete(directory.resolve("Rewards"));
Files.createDirectory(directory.resolve("Rewards"));
assertTrue(BackendConfigurationService.supportsNamedRewardFiles(directory));
assertTrue(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory));
AtomicInteger checkedRewardDirectory = new AtomicInteger();
assertFalse(BackendConfigurationService.supportsNamedRewardFiles(directory,
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory,
channel -> channel.force(true), ignored -> {
checkedRewardDirectory.incrementAndGet();
return false;
}));
assertEquals(1, checkedRewardDirectory.get());
assertFalse(BackendConfigurationService.supportsNamedRewardFiles(directory,
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory,
channel -> channel.force(true),
ignored -> { throw new java.nio.file.NoSuchFileException("vanished-reward-entry"); }));
assertFalse(BackendConfigurationService.supportsNamedRewardFiles(directory,
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(directory,
channel -> channel.force(true), pinned -> {
assertTrue(pinned.getFileAttributeView(PosixFileAttributeView.class) != null);
throw new UnsupportedOperationException("private staging creation is unavailable");
@@ -436,21 +438,21 @@ class BackendConfigurationServiceTest {
Path archive = directory.resolve("unsupported.zip");
try (var zip = FileSystems.newFileSystem(URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) {
Path plugin = Files.createDirectory(zip.getPath("/plugin"));
assertFalse(BackendConfigurationService.supportsNamedRewardFiles(plugin));
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(plugin));
}
}
@Test void namedRewardCapabilityReprobesAfterTransientStagingFailure() throws Exception {
Files.createDirectory(directory.resolve("Rewards"));
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
AtomicInteger attempts = new AtomicInteger();
BackendConfigurationService.DirectoryStagingSupport probe = ignored -> {
BackendConfigurationServiceAccess.DirectoryStagingSupport probe = ignored -> {
if (attempts.incrementAndGet() == 1) throw new IOException("temporary staging failure");
return true;
};
assertFalse(service.supportsNamedRewardFiles(probe));
assertTrue(service.supportsNamedRewardFiles(probe));
assertTrue(service.supportsNamedRewardFiles(probe));
assertFalse(BackendConfigurationServiceAccess.supportsNamedRewardFiles(service, probe));
assertTrue(BackendConfigurationServiceAccess.supportsNamedRewardFiles(service, probe));
assertTrue(BackendConfigurationServiceAccess.supportsNamedRewardFiles(service, probe));
assertEquals(2, attempts.get(), "only a successful probe may be cached for this directory");
}
@@ -459,7 +461,7 @@ class BackendConfigurationServiceTest {
Path rewards = Files.createDirectories(directory.resolve("Rewards"));
Path file = rewards.resolve("Daily.yml");
Files.writeString(file, "Money: 1\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = service.read("Rewards/Daily.yml").revision();
service.apply("Rewards/Daily.yml", "Money: 2\n", revision);
@@ -488,7 +490,7 @@ class BackendConfigurationServiceTest {
});
try {
worker.submit(() -> assertThrows(IOException.class,
() -> BackendConfigurationService.readRaw(pinned, "Daily.yml", false)))
() -> BackendConfigurationServiceAccess.readRaw(pinned, "Daily.yml", false)))
.get(2, TimeUnit.SECONDS);
} finally {
worker.shutdownNow();
@@ -514,7 +516,7 @@ class BackendConfigurationServiceTest {
assertEquals("Money: 1\n", expectedContent);
}
};
BackendConfigurationService service = new BackendConfigurationService(directory, reload);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, reload);
String revision = service.read("Rewards/Daily.yml").revision();
BackendConfigurationService.ApplyFailureException failure = assertThrows(
BackendConfigurationService.ApplyFailureException.class,
@@ -529,13 +531,13 @@ class BackendConfigurationServiceTest {
Path outside = directory.resolve("outside.yml");
Files.writeString(outside, "outside: true\n");
Files.createSymbolicLink(rewards.resolve("External.yml"), outside);
assertThrows(IOException.class, () -> BackendConfigurationService.rewardFileInventory(directory));
assertThrows(IOException.class, () -> BackendConfigurationServiceAccess.rewardFileInventory(directory));
Files.delete(rewards.resolve("External.yml"));
Files.writeString(rewards.resolve("Daily.yml"), "one: true\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = service.read("Rewards/Daily.yml").revision();
Files.writeString(rewards.resolve("daily.yml"), "two: true\n");
assertThrows(IOException.class, () -> BackendConfigurationService.rewardFileInventory(directory));
assertThrows(IOException.class, () -> BackendConfigurationServiceAccess.rewardFileInventory(directory));
assertThrows(IOException.class, () -> service.read("Rewards/Daily.yml"));
assertThrows(IOException.class, () -> service.preview("Rewards/Daily.yml", "one: false\n"));
assertThrows(IOException.class, () -> service.apply("Rewards/Daily.yml", "one: false\n", revision));
@@ -544,22 +546,22 @@ class BackendConfigurationServiceTest {
for (int index = 0; index <= BackendConfigurationService.MAX_REWARD_FILES; index++) {
Files.writeString(rewards.resolve("Reward" + index + ".yml"), "value: true\n");
}
assertThrows(IOException.class, () -> BackendConfigurationService.rewardFileInventory(directory));
assertThrows(IOException.class, () -> BackendConfigurationServiceAccess.rewardFileInventory(directory));
}
@Test void rewardInventoryBoundsIgnoredEntriesBeforeReadPreviewOrApply() throws Exception {
Path rewards = Files.createDirectories(directory.resolve("Rewards"));
Path daily = rewards.resolve("Daily.yml");
Files.writeString(daily, "Money: 1\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = service.read("Rewards/Daily.yml").revision();
for (int index = 0; index < BackendConfigurationService.MAX_REWARD_DIRECTORY_ENTRIES - 1; index++) {
for (int index = 0; index < BackendConfigurationServiceAccess.MAX_REWARD_DIRECTORY_ENTRIES - 1; index++) {
Files.createDirectory(rewards.resolve("Ignored" + index));
}
assertEquals(List.of("Daily.yml"), BackendConfigurationService.rewardFileInventory(directory));
assertEquals(List.of("Daily.yml"), BackendConfigurationServiceAccess.rewardFileInventory(directory));
assertEquals(revision, service.read("Rewards/Daily.yml").revision());
Files.createDirectory(rewards.resolve("Overflow"));
assertThrows(IOException.class, () -> BackendConfigurationService.rewardFileInventory(directory));
assertThrows(IOException.class, () -> BackendConfigurationServiceAccess.rewardFileInventory(directory));
assertThrows(IOException.class, () -> service.read("Rewards/Daily.yml"));
assertThrows(IOException.class, () -> service.preview("Rewards/Daily.yml", "Money: 2\n"));
assertThrows(IOException.class, () -> service.apply("Rewards/Daily.yml", "Money: 2\n", revision));
@@ -573,7 +575,7 @@ class BackendConfigurationServiceTest {
Files.writeString(rewards.resolve("Daily.yml"), "Money: 1\n");
Files.writeString(outside.resolve("Daily.yml"), "Money: 999\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(plugin,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(plugin,
(BackendConfigurationService.ApplyAction) ignored -> {
if (reloads.incrementAndGet() == 1) {
Files.move(rewards, plugin.resolve("Rewards-original"));
@@ -598,7 +600,7 @@ class BackendConfigurationServiceTest {
Files.writeString(rewards.resolve("Daily.yml"), "Money: 1\n");
Files.writeString(replacement.resolve("Daily.yml"), "Money: 999\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(plugin,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(plugin,
(BackendConfigurationService.ApplyAction) ignored -> {
if (reloads.incrementAndGet() == 1) {
Files.move(rewards, plugin.resolve("Rewards-original"));
@@ -642,7 +644,7 @@ class BackendConfigurationServiceTest {
assertEquals("Money: 1\n", expectedContent);
}
};
BackendConfigurationService service = new BackendConfigurationService(plugin, reload);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(plugin, reload);
String revision = service.read("Rewards/Daily.yml").revision();
BackendConfigurationService.ApplyFailureException failure = assertThrows(
BackendConfigurationService.ApplyFailureException.class,
@@ -673,7 +675,7 @@ class BackendConfigurationServiceTest {
reloads.incrementAndGet();
}
};
BackendConfigurationService service = new BackendConfigurationService(plugin, action);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(plugin, action);
String revision = service.read("Rewards/Daily.yml").revision();
BackendConfigurationService.ApplyFailureException failure = assertThrows(
BackendConfigurationService.ApplyFailureException.class,
@@ -690,7 +692,7 @@ class BackendConfigurationServiceTest {
Files.writeString(file, "Money: 1\n");
CountDownLatch firstReload = new CountDownLatch(1);
CountDownLatch allowReload = new CountDownLatch(1);
BackendConfigurationService service = new BackendConfigurationService(directory,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory,
(BackendConfigurationService.ApplyAction) ignored -> {
firstReload.countDown();
if (!allowReload.await(5, TimeUnit.SECONDS)) throw new IOException("test reload timed out");
@@ -718,7 +720,7 @@ class BackendConfigurationServiceTest {
Files.writeString(config, "Feature: false\n");
Files.writeString(external, "external: preserved\n");
Files.createSymbolicLink(directory.resolve("Config.yml.control-backup"), external);
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = service.read("Config.yml").revision();
assertThrows(java.io.IOException.class,
@@ -731,7 +733,7 @@ class BackendConfigurationServiceTest {
Path settings = directory.resolve("BungeeSettings.yml");
Files.writeString(settings, "UseBungeecord: false\nBungeeMethod: PLUGINMESSAGING\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory,
(BackendConfigurationService.ApplyAction) fileName -> {
assertEquals("BungeeSettings.yml", fileName);
if (reloads.incrementAndGet() == 1) throw new IllegalStateException("transport unavailable");
@@ -752,7 +754,7 @@ class BackendConfigurationServiceTest {
Files.writeString(config, "Feature: before\n");
AtomicInteger reloads = new AtomicInteger();
AtomicInteger moves = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory,
(BackendConfigurationService.ApplyAction) fileName -> {
if (reloads.incrementAndGet() == 1) throw new IOException("reload failed");
}, (source, target) -> {
@@ -778,7 +780,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "Feature: before\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory,
(BackendConfigurationService.ApplyAction) fileName -> {
reloads.incrementAndGet();
Files.writeString(config, "Feature: manual\n");
@@ -799,7 +801,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "Feature: before\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory,
(BackendConfigurationService.ApplyAction) fileName -> {
if (reloads.incrementAndGet() == 1) Files.writeString(config, "Feature: manual\n");
});
@@ -815,7 +817,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "Feature: before\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory,
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory,
(BackendConfigurationService.ApplyAction) fileName -> {
int reload = reloads.incrementAndGet();
if (reload <= 2) Files.writeString(config, "Feature: manual-" + reload + "\n");
@@ -831,7 +833,7 @@ class BackendConfigurationServiceTest {
@Test void quickSetupsProduceReviewableBackendAndVoteSiteChanges() throws Exception {
Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: false\nServer: PleaseSet\n");
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview proxy = service.previewQuickSetup("proxy-backend",
Map.of("server", "lobby", "method", "PLUGINMESSAGING"));
@@ -883,7 +885,7 @@ class BackendConfigurationServiceTest {
Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: true\n"
+ " VotesRequired: 35\n GiveAllPlayers: true\n GiveOnlinePlayersOnly: false\n"
+ " Broadcast: Party!\n Rewards:\n Commands: [say one, say two]\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertEquals("lobby", service.readQuickSetup("proxy-backend", Map.of()).options().get("server"));
assertEquals("REDIS", service.readQuickSetup("proxy-backend", Map.of()).options().get("method"));
@@ -901,7 +903,7 @@ class BackendConfigurationServiceTest {
Path rewards = directory.resolve("SpecialRewards.yml");
Files.writeString(rewards, "VoteParty:\n Enabled: false\n VotesRequired: 20\n"
+ " GiveAllPlayers: false\n GiveOnlinePlayersOnly: true\n Broadcast: Keep\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickState disabled = service.readQuickSetup("vote-party", Map.of());
assertEquals("false", disabled.options().get("enabled"));
@@ -909,21 +911,21 @@ class BackendConfigurationServiceTest {
"enabled", disabled.options().get("enabled"), "votesRequired", "25", "command", "",
"broadcast", "Keep", "giveAllPlayers", "false", "onlineOnly", "true"));
assertTrue(unrelated.proposal().content().contains("Enabled: false"));
service.applyQuickSetup("vote-party", Map.of("enabled", "false", "votesRequired", "25", "command", "",
BackendConfigurationServiceAccess.applyQuickSetup(service, "vote-party", Map.of("enabled", "false", "votesRequired", "25", "command", "",
"broadcast", "Keep", "giveAllPlayers", "false", "onlineOnly", "true"), unrelated.revision());
assertEquals("false", service.readQuickSetup("vote-party", Map.of()).options().get("enabled"));
BackendConfigurationService.QuickPreview enable = service.previewQuickSetup("vote-party", Map.of(
"enabled", "true", "votesRequired", "25", "command", "", "broadcast", "Keep",
"giveAllPlayers", "false", "onlineOnly", "true"));
service.applyQuickSetup("vote-party", Map.of("enabled", "true", "votesRequired", "25", "command", "",
BackendConfigurationServiceAccess.applyQuickSetup(service, "vote-party", Map.of("enabled", "true", "votesRequired", "25", "command", "",
"broadcast", "Keep", "giveAllPlayers", "false", "onlineOnly", "true"), enable.revision());
assertEquals("true", service.readQuickSetup("vote-party", Map.of()).options().get("enabled"));
}
@Test void legacyVotePartyQuickSetupPreservesEnabledWhenOmitted() throws Exception {
Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("vote-party", Map.of(
"votesRequired", "25", "command", "", "broadcast", "", "giveAllPlayers", "false",
@@ -935,7 +937,7 @@ class BackendConfigurationServiceTest {
@Test void oversizedInstalledGuidedValuesFailInsteadOfWedgingResultSubmission() throws Exception {
Files.writeString(directory.resolve("SpecialRewards.yml"),
"VoteParty:\n Broadcast: '" + "é".repeat(251) + "'\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IOException.class, () -> service.readQuickSetup("vote-party", Map.of()));
}
@@ -944,7 +946,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "AutoCreateVoteSites: true\nProcessRewards: false\nOtherSetting: keep\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory, reloads::incrementAndGet);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, reloads::incrementAndGet);
BackendConfigurationService.QuickState state = service.readQuickSetup("auto-create-vote-sites", Map.of());
assertEquals("true", state.options().get("enabled"));
@@ -952,7 +954,7 @@ class BackendConfigurationServiceTest {
Map.of("enabled", "false"));
assertEquals(List.of("changed AutoCreateVoteSites"), preview.changes());
service.applyQuickSetup("auto-create-vote-sites", Map.of("enabled", "false"), state.revision());
BackendConfigurationServiceAccess.applyQuickSetup(service, "auto-create-vote-sites", Map.of("enabled", "false"), state.revision());
YamlConfiguration applied = YamlConfiguration.loadConfiguration(config.toFile());
assertFalse(applied.getBoolean("AutoCreateVoteSites"));
assertFalse(applied.getBoolean("ProcessRewards"));
@@ -966,7 +968,7 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "VoteLogging:\n Enabled: false\n PurgeDays: 30\n UseMainMySQL: true\n"
+ " Host: private-db\n Password: keep-secret\nProcessRewards: false\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickState state = service.readQuickSetup("vote-logging", Map.of());
assertEquals(Map.of("enabled", "false", "purgeDays", "30", "useMainMySQL", "true"),
@@ -975,7 +977,7 @@ class BackendConfigurationServiceTest {
"enabled", "true", "purgeDays", "90", "useMainMySQL", "false"));
assertEquals(List.of("changed VoteLogging.Enabled", "changed VoteLogging.PurgeDays",
"changed VoteLogging.UseMainMySQL"), preview.changes());
service.applyQuickSetup("vote-logging", Map.of("enabled", "true", "purgeDays", "-1",
BackendConfigurationServiceAccess.applyQuickSetup(service, "vote-logging", Map.of("enabled", "true", "purgeDays", "-1",
"useMainMySQL", "false"), state.revision());
YamlConfiguration applied = YamlConfiguration.loadConfiguration(config.toFile());
@@ -1000,7 +1002,7 @@ class BackendConfigurationServiceTest {
+ " Commands: [existing]\n Messages:\n Player: Existing message\n");
Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: true\n"
+ " Rewards:\n Commands: [existing party]\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview reward = service.previewQuickSetup("easy-reward", Map.of(
"scope", "site", "name", "PMC", "command", "new reward", "message", "New message"));
@@ -1022,7 +1024,7 @@ class BackendConfigurationServiceTest {
+ " Commands: [old command]\n Other:\n Rewards:\n Commands: [keep other]\n"
+ "EverySiteReward:\n Commands: [keep every]\n");
AtomicInteger reloads = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory, reloads::incrementAndGet);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, reloads::incrementAndGet);
String encoded = """
{"scope":"site","site":"PMC","commands":["eco give %player% 100"],
"playerMessages":["Thanks"],"broadcastMessages":["%player% voted"],
@@ -1033,7 +1035,7 @@ class BackendConfigurationServiceTest {
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("reward-builder", options);
assertEquals("VoteSites.yml", preview.proposal().fileName());
assertEquals(preview.revision(), service.currentQuickSetupRevision("reward-builder",
assertEquals(preview.revision(), BackendConfigurationServiceAccess.currentQuickSetupRevision(service, "reward-builder",
Map.of("targetFile", "VoteSites.yml")));
YamlConfiguration proposed = new YamlConfiguration();
proposed.loadFromString(preview.proposal().content());
@@ -1054,11 +1056,11 @@ class BackendConfigurationServiceTest {
assertTrue(preview.proposal().content().contains("keep other"));
assertTrue(preview.proposal().content().contains("keep every"));
BackendConfigurationService.ApplyResult applied = service.applyQuickSetup("reward-builder", options,
BackendConfigurationService.ApplyResult applied = BackendConfigurationServiceAccess.applyQuickSetup(service, "reward-builder", options,
preview.revision());
assertEquals(1, reloads.get());
assertTrue(Files.readString(voteSites).contains("eco give %player% 100"));
assertEquals(applied.document().revision(), service.currentQuickSetupRevision("reward-builder",
assertEquals(applied.document().revision(), BackendConfigurationServiceAccess.currentQuickSetupRevision(service, "reward-builder",
Map.of("targetFile", "VoteSites.yml")));
}
@@ -1066,7 +1068,7 @@ class BackendConfigurationServiceTest {
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\nEverySiteReward:\n Commands: [old]\n");
Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n"
+ " VotesRequired: 40\n Rewards:\n Commands: [old party]\nOtherReward: keep\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview every = service.previewQuickSetup("reward-builder", Map.of(
"proposal", "{\"scope\":\"every-site\",\"commands\":[\"say every\"]}"));
@@ -1087,7 +1089,7 @@ class BackendConfigurationServiceTest {
@Test void rewardBuilderRejectsUnknownOptionsMalformedPlansAndMissingSites() throws Exception {
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("reward-builder", Map.of()));
assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("reward-builder",
@@ -1099,14 +1101,14 @@ class BackendConfigurationServiceTest {
String oversized = "{\"scope\":\"every-site\",\"commands\":[\"" + "x".repeat(64 * 1024) + "\"]}";
assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("reward-builder",
Map.of("proposal", oversized)));
assertThrows(IllegalArgumentException.class, () -> service.currentQuickSetupRevision("reward-builder",
assertThrows(IllegalArgumentException.class, () -> BackendConfigurationServiceAccess.currentQuickSetupRevision(service, "reward-builder",
Map.of("targetFile", "Config.yml")));
}
@Test void proxyBackendQuickSetupRejectsUnknownTransportMethods() throws Exception {
Files.writeString(directory.resolve("BungeeSettings.yml"),
"UseBungeecord: false\nServer: PleaseSet\nBungeeMethod: PLUGINMESSAGING\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("proxy-backend",
Map.of("server", "lobby", "method", "NOT_A_TRANSPORT")));
@@ -1121,7 +1123,7 @@ class BackendConfigurationServiceTest {
@Test void httpConnectionCodeIsRedactedFromManagedConfiguration() throws Exception {
Files.writeString(directory.resolve("BungeeSettings.yml"),
"HTTP:\n ConnectionCode: VPH1-sensitive-enrollment-code\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document read = service.read("BungeeSettings.yml");
assertFalse(read.content().contains("VPH1-sensitive-enrollment-code"));
@@ -1140,7 +1142,7 @@ class BackendConfigurationServiceTest {
com.bencodez.simpleapi.servercomm.http.HttpClientCredentialStore.saveEnrolled(directory.resolve("http"), code,
identity.issueClientCertificate("lobby-1"));
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertDoesNotThrow(() -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP")));
}
@@ -1148,7 +1150,7 @@ class BackendConfigurationServiceTest {
Path settings = directory.resolve("BungeeSettings.yml");
Files.writeString(settings, "UseBungeecord: true\nServer: lobby-1\nBungeeMethod: PLUGINMESSAGING\n"
+ "PluginMessageChannel: vp:vp\nHTTP:\n ConnectionCode: malformed\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class,
() -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP")));
@@ -1178,7 +1180,7 @@ class BackendConfigurationServiceTest {
+ "MQTT:\n ClientID: lobby\n BrokerURL: bad\nBungeeServer:\n Host: ''\n Port: 1297\n");
Files.writeString(directory.resolve("Config.yml"),
"DataStorage: SQLITE\nDatabase:\n Host: ''\n Database: ''\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class,
() -> service.previewQuickSetup("proxy-method", Map.of("method", "REDIS")));
@@ -1199,7 +1201,7 @@ class BackendConfigurationServiceTest {
@Test void pluginMessagingMethodRequiresBackendIdentityForDiagnostics() throws Exception {
Files.writeString(directory.resolve("BungeeSettings.yml"),
"UseBungeecord: true\nServer: PleaseSet\nBungeeMethod: REDIS\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("proxy-method",
Map.of("method", "PLUGINMESSAGING")));
@@ -1210,11 +1212,11 @@ class BackendConfigurationServiceTest {
Files.writeString(settings, "# Proxy settings\n#\n# Method selection\n"
+ "UseBungeecord: true\nServer: server\nBungeeMethod: PLUGINMESSAGING\n"
+ "PluginMessageChannel: vp:vp\nRedis:\n Host: localhost\n Port: 6379\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("proxy-method",
Map.of("method", "REDIS"));
BackendConfigurationService.ApplyResult applied = service.applyQuickSetup("proxy-method",
BackendConfigurationService.ApplyResult applied = BackendConfigurationServiceAccess.applyQuickSetup(service, "proxy-method",
Map.of("method", "REDIS"), preview.revision(), ignored -> { });
assertFalse(applied.rolledBack());
@@ -1229,11 +1231,11 @@ class BackendConfigurationServiceTest {
+ "PluginMessageChannel: vp:vp\nRedis:\n Host: localhost\n Port: 6379\n");
AtomicInteger fullReloads = new AtomicInteger();
AtomicInteger transportSwitches = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory, fullReloads::incrementAndGet);
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, fullReloads::incrementAndGet);
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("proxy-method",
Map.of("method", "REDIS"));
service.applyQuickSetup("proxy-method", Map.of("method", "REDIS"), preview.revision(),
BackendConfigurationServiceAccess.applyQuickSetup(service, "proxy-method", Map.of("method", "REDIS"), preview.revision(),
ignored -> transportSwitches.incrementAndGet());
assertEquals(0, fullReloads.get());
@@ -1247,13 +1249,13 @@ class BackendConfigurationServiceTest {
+ "PluginMessageChannel: vp:vp\nRedis:\n Host: localhost\n Port: 6379\n";
Files.writeString(settings, original);
AtomicInteger targeted = new AtomicInteger();
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("proxy-method",
Map.of("method", "REDIS"));
BackendConfigurationService.ApplyFailureException failure = assertThrows(
BackendConfigurationService.ApplyFailureException.class,
() -> service.applyQuickSetup("proxy-method", Map.of("method", "REDIS"), preview.revision(), ignored -> {
() -> BackendConfigurationServiceAccess.applyQuickSetup(service, "proxy-method", Map.of("method", "REDIS"), preview.revision(), ignored -> {
if (targeted.incrementAndGet() == 1) throw new IOException("transport activation failed");
}));
@@ -1296,12 +1298,12 @@ class BackendConfigurationServiceTest {
+ " Commands: ['source new reward']\n"
+ "EverySiteReward:\n"
+ " Commands: ['source every-site reward']\n";
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document before = service.read("VoteSites.yml");
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites",
Map.of("sourceContent", source));
String recoveryRevision = service.proposedQuickSetupRevision("sync-vote-sites", preview);
String recoveryRevision = BackendConfigurationServiceAccess.proposedQuickSetupRevision(service, "sync-vote-sites", preview);
YamlConfiguration proposal = new YamlConfiguration();
proposal.options().parseComments(true);
proposal.loadFromString(preview.proposal().content());
@@ -1318,7 +1320,7 @@ class BackendConfigurationServiceTest {
assertTrue(preview.proposal().content().contains("# synchronized field"));
assertTrue(preview.changes().stream().noneMatch(change -> change.toLowerCase().contains("reward")));
BackendConfigurationService.ApplyResult result = service.applyQuickSetup("sync-vote-sites",
BackendConfigurationService.ApplyResult result = BackendConfigurationServiceAccess.applyQuickSetup(service, "sync-vote-sites",
Map.of("sourceContent", source), preview.revision());
assertEquals(recoveryRevision, result.document().revision());
String applied = Files.readString(voteSites);
@@ -1332,7 +1334,7 @@ class BackendConfigurationServiceTest {
@Test void voteSitesSyncMatchesRootSitesAndFieldsCaseInsensitively() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "CaseInsensitiveYMLFiles: true\n");
Files.writeString(directory.resolve("VoteSites.yml"), "votesites:\n pmc:\n name: Target\n Rewards:\n Commands: ['keep']\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of(
"sourceContent", "VOTESITES:\n PMC:\n Name: Source\n VoteURL: https://example.invalid/vote\n"));
@@ -1350,22 +1352,22 @@ class BackendConfigurationServiceTest {
Path config = directory.resolve("Config.yml");
Files.writeString(config, "CaseInsensitiveYMLFiles: true\nMessage: before\n");
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Name: Target\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
Map<String, String> source = Map.of("sourceContent", "VoteSites:\n PMC:\n Name: Source\n");
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", source);
Files.writeString(config, "CaseInsensitiveYMLFiles: true\nMessage: independently edited\n");
assertDoesNotThrow(() -> service.applyQuickSetup("sync-vote-sites", source, preview.revision()));
assertDoesNotThrow(() -> BackendConfigurationServiceAccess.applyQuickSetup(service, "sync-vote-sites", source, preview.revision()));
BackendConfigurationService.QuickPreview policyPreview = service.previewQuickSetup("sync-vote-sites", source);
Files.writeString(config, "CaseInsensitiveYMLFiles: false\nMessage: independently edited\n");
assertThrows(BackendConfigurationService.StaleRevisionException.class,
() -> service.applyQuickSetup("sync-vote-sites", source, policyPreview.revision()));
() -> BackendConfigurationServiceAccess.applyQuickSetup(service, "sync-vote-sites", source, policyPreview.revision()));
}
@Test void voteSitesSyncDropsUnrestorableSourceCredentialComments() throws Exception {
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Name: Target\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String source = "VoteSites:\n # Password: " + BackendConfigurationService.REDACTED + "\n"
+ " # public source note\n PMC:\n Name: Source # Token: "
+ BackendConfigurationService.REDACTED + "\n";
@@ -1375,7 +1377,7 @@ class BackendConfigurationServiceTest {
assertFalse(preview.proposal().content().contains(BackendConfigurationService.REDACTED));
assertTrue(preview.proposal().content().contains("public source note"));
BackendConfigurationService.ApplyResult applied = service.applyQuickSetup("sync-vote-sites",
BackendConfigurationService.ApplyResult applied = BackendConfigurationServiceAccess.applyQuickSetup(service, "sync-vote-sites",
Map.of("sourceContent", source), preview.revision());
YamlConfiguration installed = new YamlConfiguration();
installed.loadFromString(applied.document().content());
@@ -1386,7 +1388,7 @@ class BackendConfigurationServiceTest {
Path voteSites = directory.resolve("VoteSites.yml");
Files.writeString(voteSites, "VoteSites:\n PMC:\n"
+ " Name: Target # Password: target-comment-secret\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
String source = "VoteSites:\n PMC:\n Name: Source # public source comment\n";
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites",
@@ -1397,7 +1399,7 @@ class BackendConfigurationServiceTest {
assertThrows(IllegalArgumentException.class, () -> service.apply("VoteSites.yml",
preview.proposal().content(), editorSnapshot.revision()));
service.applyQuickSetup("sync-vote-sites", Map.of("sourceContent", source), preview.revision());
BackendConfigurationServiceAccess.applyQuickSetup(service, "sync-vote-sites", Map.of("sourceContent", source), preview.revision());
String applied = Files.readString(voteSites);
assertTrue(applied.contains("Name: Source"));
assertTrue(applied.contains("target-comment-secret"));
@@ -1407,7 +1409,7 @@ class BackendConfigurationServiceTest {
Files.writeString(directory.resolve("Config.yml"), "caseinsensitiveymlfiles: false\n");
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Name: Upper target\n"
+ " pmc:\n Name: Lower target\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of(
"sourceContent", "votesites:\n PMC:\n name: Updated upper\n"));
@@ -1422,7 +1424,7 @@ class BackendConfigurationServiceTest {
Files.writeString(directory.resolve("Config.yml"), "Message: no casing policy configured\n");
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Name: Upper target\n"
+ " pmc:\n Name: Lower target\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of(
"sourceContent", "VoteSites:\n PMC:\n Name: Updated upper\n"));
@@ -1435,7 +1437,7 @@ class BackendConfigurationServiceTest {
@Test void voteSitesSyncRejectsMalformedOrMissingSourceSections() throws Exception {
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("sync-vote-sites",
Map.of("sourceContent", "not: [yaml")));
@@ -1447,7 +1449,7 @@ class BackendConfigurationServiceTest {
@Test void voteSitesSyncSkipsRewardOnlySites() throws Exception {
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n Existing:\n Name: Existing\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of(
"sourceContent", "VoteSites:\n RewardOnly:\n Rewards:\n Commands: ['say ignored']\n"));
@@ -1460,7 +1462,7 @@ class BackendConfigurationServiceTest {
@Test void voteSitesSyncPreservesTargetsBelowRewardOnlyWrappers() throws Exception {
Files.writeString(directory.resolve("VoteSites.yml"),
"VoteSites:\n Example:\n Name: Target\n Metadata: keep\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of(
"sourceContent", "VoteSites:\n Example:\n Name: Source\n Metadata:\n"
@@ -1475,7 +1477,7 @@ class BackendConfigurationServiceTest {
@Test void voteSitesSyncDoesNotReplaceTargetSectionsContainingRewardDescendants() throws Exception {
Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n Example:\n Name: Target\n"
+ " Metadata:\n Owner: keep\n Rewards:\n Commands: ['target reward']\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("sync-vote-sites", Map.of(
"sourceContent", "VoteSites:\n Example:\n Name: Source\n Metadata: source scalar\n"));
@@ -1491,7 +1493,7 @@ class BackendConfigurationServiceTest {
@Test void fullBungeeSettingsRejectsUnknownTransportMethods() throws Exception {
Files.writeString(directory.resolve("BungeeSettings.yml"),
"UseBungeecord: true\nServer: lobby\nBungeeMethod: PLUGINMESSAGING\n");
BackendConfigurationService service = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService service = BackendConfigurationServiceAccess.create(directory, () -> { });
assertThrows(IllegalArgumentException.class, () -> service.preview("BungeeSettings.yml",
"UseBungeecord: true\nServer: lobby\nBungeeMethod: NOT_A_TRANSPORT\n"));
@@ -0,0 +1,30 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.scheduler.BukkitTask;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.backendproxy.BackendProxyHandler;
import com.bencodez.votingplugin.proxy.BungeeMethod;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
import com.bencodez.votingplugin.proxy.control.HostedControlManager;
import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration;
import com.bencodez.votingplugin.util.ControlCredentialFile;
import com.bencodez.votingplugin.util.ControlCredentialFile.AutoEnrollmentInspection;
import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment;
/** Typed test-only access to package-private BackendControlAutoEnrollment seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendControlAutoEnrollmentAccess {
private BackendControlAutoEnrollmentAccess() { }
public static void send(BackendControlAutoEnrollment target) { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlAutoEnrollment", target, "send", new String[]{}, new Object[]{}); }
}
@@ -1,4 +1,7 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.BackendControlAutoEnrollment;
import com.bencodez.votingplugin.control.ControlEnrollmentAuthenticator;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -120,7 +123,7 @@ class BackendControlAutoEnrollmentTest {
"backend-a", requestId, false, challenge, resultProof));
verify(plugin).startBackendControlConnectorForEnrollment(enrollment);
org.mockito.Mockito.clearInvocations(messages);
enrollment.send();
BackendControlAutoEnrollmentAccess.send(enrollment);
ArgumentCaptor<JsonEnvelope> provedRequest = ArgumentCaptor.forClass(JsonEnvelope.class);
verify(messages).sendMessage(provedRequest.capture());
@@ -142,7 +145,7 @@ class BackendControlAutoEnrollmentTest {
"backend-a", requestId, false, challenge, resultProof));
verify(plugin, times(2)).startBackendControlConnectorForEnrollment(enrollment);
org.mockito.Mockito.clearInvocations(messages);
enrollment.send();
BackendControlAutoEnrollmentAccess.send(enrollment);
ArgumentCaptor<JsonEnvelope> retriedRequest = ArgumentCaptor.forClass(JsonEnvelope.class);
verify(messages).sendMessage(retriedRequest.capture());
VotingPluginWire.ControlEnrollmentRequest retried =
@@ -0,0 +1,81 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import com.bencodez.votingplugin.control.BackendControlConnector.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess.StoredResult;
import com.google.gson.JsonObject;
/** Typed test-only access to package-private BackendControlConnector seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendControlConnectorAccess {
private BackendControlConnectorAccess() { }
public static PluginDeploymentService.Task deploymentTask(JsonObject task) { return (PluginDeploymentService.Task) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "deploymentTask", new String[]{"JsonObject"}, new Object[]{task}); }
public static BackendControlConnectorAccess.InspectionTaskResult executeInspection(BackendControlConnector target, JsonObject query) { return BackendControlConnectorAccess.InspectionTaskResult.view(ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", target, "executeInspection", new String[]{"JsonObject"}, new Object[]{query})); }
public static String inspectionFailureMessage(Throwable ignored) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "inspectionFailureMessage", new String[]{"Throwable"}, new Object[]{ignored}); }
public static boolean taskLeaseExpired(BackendControlConnectorAccess.Response response) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "taskLeaseExpired", new String[]{"Response"}, new Object[]{response}); }
public static boolean operationNotFound(BackendControlConnectorAccess.Response response) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "operationNotFound", new String[]{"Response"}, new Object[]{response}); }
public static StoredResult abortedIntent(StoredResult pending) { return com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess.StoredResult.view(ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "abortedIntent", new String[]{"StoredResult"}, new Object[]{pending})); }
public static StoredResult committedInstalledForAttempt(BackendConfigurationService configurations, StoredResult pending, String attemptId) throws IOException { return com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess.StoredResult.view(ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "committedInstalledForAttempt", new String[]{"BackendConfigurationService", "StoredResult", "String"}, new Object[]{configurations, pending, attemptId})); }
public static void afterResultAcknowledged(BackendControlConnectorAccess.ResultSubmission submission, BackendControlConnectorAccess.ResultAcknowledgement acknowledged) throws Exception { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "afterResultAcknowledged", new String[]{"ResultSubmission", "ResultAcknowledgement"}, new Object[]{submission, acknowledged}); }
public static String operationFailureMessage(String type, Throwable failure) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "operationFailureMessage", new String[]{"String", "Throwable"}, new Object[]{type, failure}); }
public static String operationFailureCode(String type) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "operationFailureCode", new String[]{"String"}, new Object[]{type}); }
public static String operationFailureCode(String type, Throwable failure) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "operationFailureCode", new String[]{"String", "Throwable"}, new Object[]{type, failure}); }
public static String boundedResultMessage(String message) { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "boundedResultMessage", new String[]{"String"}, new Object[]{message}); }
public static List<String> boundedResultChanges(List<String> changes) { return (List<String>) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "boundedResultChanges", new String[]{"List"}, new Object[]{changes}); }
public static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "quickSetupCapabilityAccepted", new String[]{"String", "boolean", "boolean", "boolean"}, new Object[]{preset, quickSetupsAccepted, votePartySetupsAccepted, voteSitesSyncAccepted}); }
public static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted, Map<String, String> options) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "quickSetupCapabilityAccepted", new String[]{"String", "boolean", "boolean", "boolean", "Map"}, new Object[]{preset, quickSetupsAccepted, votePartySetupsAccepted, voteSitesSyncAccepted, options}); }
public static boolean proxyMethodApplyCapabilityAccepted(String method, boolean proxyMethodV2Accepted) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "proxyMethodApplyCapabilityAccepted", new String[]{"String", "boolean"}, new Object[]{method, proxyMethodV2Accepted}); }
public static void addCapabilities(JsonObject body) { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "addCapabilities", new String[]{"JsonObject"}, new Object[]{body}); }
public static void addCapabilities(JsonObject body, boolean rewardFilesSupported, boolean deploymentReady) { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "addCapabilities", new String[]{"JsonObject", "boolean", "boolean"}, new Object[]{body, rewardFilesSupported, deploymentReady}); }
public static boolean negotiatedCapability(JsonObject node, String capability, boolean current) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "negotiatedCapability", new String[]{"JsonObject", "String", "boolean"}, new Object[]{node, capability, current}); }
public static long inspectionRetryDelayMillis(int failures) { return (Long) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "inspectionRetryDelayMillis", new String[]{"int"}, new Object[]{failures}); }
public static void requireFileCapability(boolean accepted) { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "requireFileCapability", new String[]{"boolean"}, new Object[]{accepted}); }
public static Map<String, String> resultQuickOptions(String preset, Map<String, String> options) { return (Map<String, String>) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "resultQuickOptions", new String[]{"String", "Map"}, new Object[]{preset, options}); }
public static Map<String, String> resultQuickReadOptions(String preset, Map<String, String> state, Map<String, String> requestOptions) { return (Map<String, String>) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "resultQuickReadOptions", new String[]{"String", "Map", "Map"}, new Object[]{preset, state, requestOptions}); }
public static boolean awaitInspectionShutdown(ScheduledExecutorService executor) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "awaitInspectionShutdown", new String[]{"ScheduledExecutorService"}, new Object[]{executor}); }
public static void awaitShutdown(ScheduledExecutorService executor, CompletableFuture<Void> operation) { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector", null, "awaitShutdown", new String[]{"ScheduledExecutorService", "CompletableFuture"}, new Object[]{executor, operation}); }
public static final class Response implements ReflectiveAccess.View {
public Response() { this(ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendControlConnector$Response", new String[]{}, new Object[]{})); }
private final Object delegate;
private Response(Object delegate) { this.delegate = delegate; }
public static Response view(Object delegate) { return delegate == null ? null : new Response(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof Response value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public Response(int status, String body) { this(ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendControlConnector$Response", new String[]{"int", "String"}, new Object[]{status, body})); }
public int status() { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector$Response", delegate, "status", new String[]{}, new Object[]{}); }
public String body() { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector$Response", delegate, "body", new String[]{}, new Object[]{}); }
}
@FunctionalInterface
public interface ResultSubmission {
void submit() throws Exception;
}
@FunctionalInterface
public interface ResultAcknowledgement {
void acknowledge() throws Exception;
}
public static final class InspectionTaskResult implements ReflectiveAccess.View {
private final Object delegate;
private InspectionTaskResult(Object delegate) { this.delegate = delegate; }
public static InspectionTaskResult view(Object delegate) { return delegate == null ? null : new InspectionTaskResult(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof InspectionTaskResult value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public boolean success() { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector$InspectionTaskResult", delegate, "success", new String[]{}, new Object[]{}); }
public String code() { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector$InspectionTaskResult", delegate, "code", new String[]{}, new Object[]{}); }
public String message() { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector$InspectionTaskResult", delegate, "message", new String[]{}, new Object[]{}); }
public JsonObject data() { return (JsonObject) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlConnector$InspectionTaskResult", delegate, "data", new String[]{}, new Object[]{}); }
}
}
@@ -1,4 +1,10 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.BackendConfigurationService;
import com.bencodez.votingplugin.control.BackendControlConnector;
import com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess;
import com.bencodez.votingplugin.control.ControlInspectionService;
import com.bencodez.votingplugin.control.PluginDeploymentService;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -23,7 +29,7 @@ import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult;
import com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess.StoredResult;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -59,7 +65,7 @@ class BackendControlConnectorProtocolTest {
field.setAccessible(true);
assertNull(field.get(connector));
JsonObject body = new JsonObject();
BackendControlConnector.addCapabilities(body, false, field.get(connector) != null);
BackendControlConnectorAccess.addCapabilities(body, false, field.get(connector) != null);
assertFalse(body.getAsJsonArray("capabilities").asList().stream()
.anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString())));
org.mockito.Mockito.verify(plugin, org.mockito.Mockito.never()).getServer();
@@ -68,21 +74,21 @@ class BackendControlConnectorProtocolTest {
}
@Test void onlyTheLeaseExpiryConflictRequestsAResultReclaim() {
assertTrue(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(409,
assertTrue(BackendControlConnectorAccess.taskLeaseExpired(new BackendControlConnectorAccess.Response(409,
"{\"error\":{\"code\":\"TASK_LEASE_EXPIRED\"}}")));
assertFalse(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(409,
assertFalse(BackendControlConnectorAccess.taskLeaseExpired(new BackendControlConnectorAccess.Response(409,
"{\"error\":{\"code\":\"SESSION_MISMATCH\"}}")));
assertFalse(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(500, "not-json")));
assertTrue(BackendControlConnector.operationNotFound(new BackendControlConnector.Response(404,
assertFalse(BackendControlConnectorAccess.taskLeaseExpired(new BackendControlConnectorAccess.Response(500, "not-json")));
assertTrue(BackendControlConnectorAccess.operationNotFound(new BackendControlConnectorAccess.Response(404,
"{\"error\":{\"code\":\"OPERATION_NOT_FOUND\"}}")));
assertFalse(BackendControlConnector.operationNotFound(new BackendControlConnector.Response(404,
assertFalse(BackendControlConnectorAccess.operationNotFound(new BackendControlConnectorAccess.Response(404,
"{\"error\":{\"code\":\"NODE_NOT_FOUND\"}}")));
}
@Test void deploymentTaskRejectsUnknownV1Fields() {
JsonObject task = deploymentTask();
task.addProperty("unexpected", "value");
assertThrows(IllegalArgumentException.class, () -> BackendControlConnector.deploymentTask(task));
assertThrows(IllegalArgumentException.class, () -> BackendControlConnectorAccess.deploymentTask(task));
}
private static JsonObject deploymentTask() {
@@ -99,7 +105,7 @@ class BackendControlConnectorProtocolTest {
JsonObject anticipated = new JsonObject();
anticipated.addProperty("attemptId", "00000000-0000-0000-0000-000000000199");
StoredResult recovered = BackendControlConnector.abortedIntent(
StoredResult recovered = BackendControlConnectorAccess.abortedIntent(
new StoredResult(anticipated, true, false, false));
assertTrue(recovered.committed());
@@ -110,7 +116,7 @@ class BackendControlConnectorProtocolTest {
@Test void installedFileIntentRecoveryRebuildsMaskedContent() throws Exception {
Files.writeString(directory.resolve("Config.yml"), "Database:\n Password: keep-me\nDebug: true\n");
BackendConfigurationService configurations = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService configurations = BackendConfigurationServiceAccess.create(directory, () -> { });
BackendConfigurationService.Document installed = configurations.read("Config.yml");
JsonObject configuration = new JsonObject();
configuration.addProperty("domain", "file");
@@ -118,7 +124,7 @@ class BackendControlConnectorProtocolTest {
JsonObject intent = new JsonObject();
intent.addProperty("revision", installed.revision());
intent.add("configuration", configuration);
StoredResult recovered = BackendControlConnector.committedInstalledForAttempt(configurations,
StoredResult recovered = BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
new StoredResult(intent, false, false, false), "00000000-0000-0000-0000-000000000198");
assertTrue(recovered.committed());
@@ -129,7 +135,7 @@ class BackendControlConnectorProtocolTest {
}
@Test void changedMalformedFilesAbortPendingRecoveryBeforeYamlParsing() throws Exception {
BackendConfigurationService configurations = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService configurations = BackendConfigurationServiceAccess.create(directory, () -> { });
for (String fileName : List.of("Config.yml", "Rewards/Daily.yml")) {
Path file = directory.resolve(fileName);
Files.createDirectories(file.getParent());
@@ -145,10 +151,10 @@ class BackendControlConnectorProtocolTest {
StoredResult pending = new StoredResult(intent, false, false, false);
Files.writeString(file, "Money: [\n");
assertThrows(IOException.class, () -> configurations.read(fileName));
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertTrue(BackendControlConnector.abortedIntent(pending).committed());
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
assertTrue(BackendControlConnectorAccess.abortedIntent(pending).committed());
Files.writeString(file, "Money: 1\n");
assertEquals(revision, BackendControlConnector.committedInstalledForAttempt(configurations,
assertEquals(revision, BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
pending, "attempt").result().get("revision").getAsString());
}
}
@@ -157,7 +163,7 @@ class BackendControlConnectorProtocolTest {
Path rewards = Files.createDirectory(directory.resolve("Rewards"));
Path daily = rewards.resolve("Daily.yml");
Files.writeString(daily, "Money: 1\n");
BackendConfigurationService configurations = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService configurations = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = configurations.read("Rewards/Daily.yml").revision();
JsonObject configuration = new JsonObject();
configuration.addProperty("domain", "file");
@@ -168,25 +174,25 @@ class BackendControlConnectorProtocolTest {
intent.add("configuration", configuration);
StoredResult pending = new StoredResult(intent, false, false, false);
Files.delete(daily);
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertTrue(BackendControlConnector.abortedIntent(pending).committed());
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
assertTrue(BackendControlConnectorAccess.abortedIntent(pending).committed());
Files.delete(rewards);
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
Files.createDirectory(rewards);
Files.createSymbolicLink(daily, directory.resolve("not-a-reward.yml"));
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
Files.delete(daily);
Files.createDirectory(daily);
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
Files.delete(daily);
Files.writeString(daily, "x".repeat(BackendConfigurationService.MAX_CONTENT_BYTES + 1));
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
Files.write(daily, new byte[] {(byte) 0xC3, (byte) 0x28});
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
Files.writeString(daily, "Money: 1\n");
assertEquals(revision, BackendControlConnector.committedInstalledForAttempt(configurations,
assertEquals(revision, BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
pending, "attempt").result().get("revision").getAsString());
assertFalse(BackendConfigurationService.namedRewardCannotBeConfirmed(new IOException("transient read failure")));
assertFalse(BackendConfigurationServiceAccess.namedRewardCannotBeConfirmed(new IOException("transient read failure")));
}
@Test void fifoNamedRewardIntentTerminatesWithoutOpeningItsBody() throws Exception {
@@ -195,7 +201,7 @@ class BackendControlConnectorProtocolTest {
Path rewards = Files.createDirectory(directory.resolve("Rewards"));
Path daily = rewards.resolve("Daily.yml");
Files.writeString(daily, "Money: 1\n");
BackendConfigurationService configurations = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService configurations = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = configurations.read("Rewards/Daily.yml").revision();
JsonObject configuration = new JsonObject();
configuration.addProperty("domain", "file");
@@ -207,14 +213,14 @@ class BackendControlConnectorProtocolTest {
Process create = new ProcessBuilder("/usr/bin/mkfifo", daily.toString()).start();
assertTrue(create.waitFor(5, TimeUnit.SECONDS));
assertEquals(0, create.exitValue());
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations,
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
new StoredResult(intent, false, false, false), "attempt"));
}
@Test void namedRewardRecoveryIgnoresUnrelatedInvalidInventoryEntries() throws Exception {
Path rewards = Files.createDirectory(directory.resolve("Rewards"));
Files.writeString(rewards.resolve("Daily.yml"), "Money: 1\n");
BackendConfigurationService configurations = new BackendConfigurationService(directory, () -> { });
BackendConfigurationService configurations = BackendConfigurationServiceAccess.create(directory, () -> { });
String revision = configurations.read("Rewards/Daily.yml").revision();
JsonObject configuration = new JsonObject();
configuration.addProperty("domain", "file");
@@ -228,21 +234,21 @@ class BackendControlConnectorProtocolTest {
Path unsafe = rewards.resolve("Unrelated.yml");
Files.createSymbolicLink(unsafe, directory.resolve("outside.yml"));
assertThrows(IOException.class, () -> configurations.read("Rewards/Daily.yml"));
assertEquals(revision, BackendControlConnector.committedInstalledForAttempt(configurations,
assertEquals(revision, BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
pending, "attempt").result().get("revision").getAsString());
Files.delete(unsafe);
Path ambiguous = rewards.resolve("daily.yml");
Files.writeString(ambiguous, "Money: 2\n");
assertThrows(IOException.class, () -> configurations.read("Rewards/Daily.yml"));
assertNull(BackendControlConnector.committedInstalledForAttempt(configurations, pending, "attempt"));
assertTrue(BackendControlConnector.abortedIntent(pending).committed());
assertNull(BackendControlConnectorAccess.committedInstalledForAttempt(configurations, pending, "attempt"));
assertTrue(BackendControlConnectorAccess.abortedIntent(pending).committed());
Files.delete(ambiguous);
Path oversized = rewards.resolve("Oversized.yml");
Files.writeString(oversized, "x".repeat(BackendConfigurationService.MAX_CONTENT_BYTES + 1));
assertThrows(IOException.class, () -> configurations.read("Rewards/Daily.yml"));
assertEquals(revision, BackendControlConnector.committedInstalledForAttempt(configurations,
assertEquals(revision, BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
pending, "attempt").result().get("revision").getAsString());
Files.delete(oversized);
@@ -250,18 +256,18 @@ class BackendControlConnectorProtocolTest {
Files.writeString(rewards.resolve("Extra" + index + ".yml"), "Money: 0\n");
}
assertThrows(IOException.class, () -> configurations.read("Rewards/Daily.yml"));
assertEquals(revision, BackendControlConnector.committedInstalledForAttempt(configurations,
assertEquals(revision, BackendControlConnectorAccess.committedInstalledForAttempt(configurations,
pending, "attempt").result().get("revision").getAsString());
}
@Test void registrationRequiresFileControlButAllowsQuickSetupToRemainOptional() {
assertThrows(RuntimeException.class, () -> BackendControlConnector.requireFileCapability(false));
assertDoesNotThrow(() -> BackendControlConnector.requireFileCapability(true));
assertThrows(RuntimeException.class, () -> BackendControlConnectorAccess.requireFileCapability(false));
assertDoesNotThrow(() -> BackendControlConnectorAccess.requireFileCapability(true));
}
@Test void registrationAdvertisesCommentPreservingFilesAsAnOptionalCapability() {
JsonObject registration = new JsonObject();
BackendControlConnector.addCapabilities(registration, true, false);
BackendControlConnectorAccess.addCapabilities(registration, true, false);
JsonArray advertised = registration.getAsJsonArray("capabilities");
assertTrue(advertised.asList().stream()
@@ -287,12 +293,12 @@ class BackendControlConnectorProtocolTest {
@Test void deploymentCapabilityIsOnlyAddedWhenStagingWasPrepared() {
JsonObject unavailable = new JsonObject();
BackendControlConnector.addCapabilities(unavailable, true, false);
BackendControlConnectorAccess.addCapabilities(unavailable, true, false);
assertFalse(unavailable.getAsJsonArray("capabilities").asList().stream()
.anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString())));
JsonObject ready = new JsonObject();
BackendControlConnector.addCapabilities(ready, true, true);
BackendControlConnectorAccess.addCapabilities(ready, true, true);
assertTrue(ready.getAsJsonArray("capabilities").asList().stream()
.anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString())));
assertTrue(ready.getAsJsonArray("capabilities").asList().stream()
@@ -301,7 +307,7 @@ class BackendControlConnectorProtocolTest {
@Test void unsupportedFilesystemDoesNotAdvertiseNamedRewards() {
JsonObject registration = new JsonObject();
BackendControlConnector.addCapabilities(registration, false, true);
BackendControlConnectorAccess.addCapabilities(registration, false, true);
assertFalse(registration.getAsJsonArray("capabilities").asList().stream()
.anyMatch(value -> "config.reward-files.v1".equals(value.getAsString())));
assertTrue(registration.getAsJsonArray("capabilities").asList().stream()
@@ -312,36 +318,36 @@ class BackendControlConnectorProtocolTest {
@Test void heartbeatRetainsOmittedCapabilitiesAndHonorsExplicitReplacement() {
JsonObject omitted = new JsonObject();
assertTrue(BackendControlConnector.negotiatedCapability(omitted, "config.files.v1", true));
assertTrue(BackendControlConnectorAccess.negotiatedCapability(omitted, "config.files.v1", true));
JsonObject explicit = new JsonObject();
JsonArray accepted = new JsonArray();
accepted.add("config.quick-setup.v1");
explicit.add("acceptedCapabilities", accepted);
assertFalse(BackendControlConnector.negotiatedCapability(explicit, "config.files.v1", true));
assertTrue(BackendControlConnector.negotiatedCapability(explicit, "config.quick-setup.v1", false));
assertFalse(BackendControlConnectorAccess.negotiatedCapability(explicit, "config.files.v1", true));
assertTrue(BackendControlConnectorAccess.negotiatedCapability(explicit, "config.quick-setup.v1", false));
}
@Test void repeatedInspectionFailuresUseBoundedExponentialBackoff() {
assertEquals(0, BackendControlConnector.inspectionRetryDelayMillis(0));
assertEquals(1000, BackendControlConnector.inspectionRetryDelayMillis(1));
assertEquals(2000, BackendControlConnector.inspectionRetryDelayMillis(2));
assertEquals(256000, BackendControlConnector.inspectionRetryDelayMillis(9));
assertEquals(300000, BackendControlConnector.inspectionRetryDelayMillis(10));
assertEquals(300000, BackendControlConnector.inspectionRetryDelayMillis(30));
assertEquals(0, BackendControlConnectorAccess.inspectionRetryDelayMillis(0));
assertEquals(1000, BackendControlConnectorAccess.inspectionRetryDelayMillis(1));
assertEquals(2000, BackendControlConnectorAccess.inspectionRetryDelayMillis(2));
assertEquals(256000, BackendControlConnectorAccess.inspectionRetryDelayMillis(9));
assertEquals(300000, BackendControlConnectorAccess.inspectionRetryDelayMillis(10));
assertEquals(300000, BackendControlConnectorAccess.inspectionRetryDelayMillis(30));
}
@Test void voteSitesSyncRequiresBothNegotiatedCapabilities() {
assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false, false));
assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, false, true));
assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false, true));
assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false, false));
assertFalse(BackendControlConnectorAccess.quickSetupCapabilityAccepted("sync-vote-sites", true, false, false));
assertFalse(BackendControlConnectorAccess.quickSetupCapabilityAccepted("sync-vote-sites", false, false, true));
assertTrue(BackendControlConnectorAccess.quickSetupCapabilityAccepted("sync-vote-sites", true, false, true));
assertTrue(BackendControlConnectorAccess.quickSetupCapabilityAccepted("common-settings", true, false, false));
}
@Test void httpProxyMethodApplyRequiresNegotiatedV2Capability() {
assertFalse(BackendControlConnector.proxyMethodApplyCapabilityAccepted("HTTP", false));
assertTrue(BackendControlConnector.proxyMethodApplyCapabilityAccepted("HTTP", true));
assertTrue(BackendControlConnector.proxyMethodApplyCapabilityAccepted("PLUGINMESSAGING", false));
assertFalse(BackendControlConnectorAccess.proxyMethodApplyCapabilityAccepted("HTTP", false));
assertTrue(BackendControlConnectorAccess.proxyMethodApplyCapabilityAccepted("HTTP", true));
assertTrue(BackendControlConnectorAccess.proxyMethodApplyCapabilityAccepted("PLUGINMESSAGING", false));
}
@Test void httpQuickRequestsRejectMissingV2BeforeTouchingConfiguration() throws Exception {
@@ -368,30 +374,30 @@ class BackendControlConnectorProtocolTest {
}
@Test void votePartyRequiresItsVersionedCapability() {
assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false));
assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false));
assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false));
assertFalse(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", true, false, false));
assertFalse(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", false, true, false));
assertTrue(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", true, true, false));
}
@Test void legacyVotePartyOptionsUseV1ButEnabledRequiresV2() {
assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false,
assertTrue(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", true, false, false,
Map.of("threshold", "10")));
assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false,
assertFalse(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", true, false, false,
Map.of("enabled", "true")));
assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false,
assertFalse(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", false, true, false,
Map.of("enabled", "true")));
assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false,
assertTrue(BackendControlConnectorAccess.quickSetupCapabilityAccepted("vote-party", true, true, false,
Map.of("enabled", "true")));
Map<String, String> state = Map.of("enabled", "false", "votesRequired", "20");
assertEquals(Map.of("votesRequired", "20"),
BackendControlConnector.resultQuickReadOptions("vote-party", state, Map.of()));
assertEquals(state, BackendControlConnector.resultQuickReadOptions("vote-party", state,
BackendControlConnectorAccess.resultQuickReadOptions("vote-party", state, Map.of()));
assertEquals(state, BackendControlConnectorAccess.resultQuickReadOptions("vote-party", state,
Map.of("enabled", "false")));
}
@Test void rewardBuilderResultsKeepOnlyTheSafeRecoveryTarget() {
String proposal = "{\"scope\":\"site\",\"site\":\"PMC\",\"commands\":[\"secret command\"]}";
Map<String, String> result = BackendControlConnector.resultQuickOptions("reward-builder",
Map<String, String> result = BackendControlConnectorAccess.resultQuickOptions("reward-builder",
Map.of("proposal", proposal));
assertEquals(Map.of("targetFile", "VoteSites.yml"), result);
@@ -403,40 +409,40 @@ class BackendControlConnectorProtocolTest {
"/srv/private/VoteSites.yml jdbc:mysql://database.internal user=secret");
assertEquals("Configuration file is unavailable or unreadable",
BackendControlConnector.operationFailureMessage("READ", failure));
BackendControlConnectorAccess.operationFailureMessage("READ", failure));
assertEquals("The backend could not prepare a configuration preview",
BackendControlConnector.operationFailureMessage("PREVIEW", failure));
BackendControlConnectorAccess.operationFailureMessage("PREVIEW", failure));
assertEquals("The backend could not apply the managed configuration",
BackendControlConnector.operationFailureMessage("APPLY", failure));
assertFalse(BackendControlConnector.operationFailureMessage("READ", failure).contains("/srv"));
assertEquals("CONFIGURATION_MISSING", BackendControlConnector.operationFailureCode("READ",
BackendControlConnectorAccess.operationFailureMessage("APPLY", failure));
assertFalse(BackendControlConnectorAccess.operationFailureMessage("READ", failure).contains("/srv"));
assertEquals("CONFIGURATION_MISSING", BackendControlConnectorAccess.operationFailureCode("READ",
new java.nio.file.NoSuchFileException("/srv/private/Config.yml")));
assertEquals("CONFIGURATION_UNREADABLE", BackendControlConnector.operationFailureCode("READ",
assertEquals("CONFIGURATION_UNREADABLE", BackendControlConnectorAccess.operationFailureCode("READ",
new java.nio.file.AccessDeniedException("/srv/private/Config.yml")));
}
@Test void unavailableConfigurationReadsPreserveV1CodeAndUseASafeReason() {
// Existing config.files.v1 peers retain the negotiated code while the
// message gives a useful reason without exposing sensitive paths.
assertEquals("READ_FAILED", BackendControlConnector.operationFailureCode("READ",
assertEquals("READ_FAILED", BackendControlConnectorAccess.operationFailureCode("READ",
new IOException("/srv/private/Config.yml")));
assertEquals("Configuration file is unavailable or unreadable",
BackendControlConnector.operationFailureMessage("READ", new IOException("/srv/private/Config.yml")));
BackendControlConnectorAccess.operationFailureMessage("READ", new IOException("/srv/private/Config.yml")));
}
@Test void networkHealthRequiresItsExplicitCapability() {
BackendControlConnector connector = org.mockito.Mockito.mock(BackendControlConnector.class, org.mockito.Mockito.CALLS_REAL_METHODS);
assertEquals("UNAVAILABLE", connector.executeInspection(JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).code());
assertEquals("UNAVAILABLE", BackendControlConnectorAccess.executeInspection(connector, JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).code());
}
@Test void namedRewardInventoryRequiresItsExplicitCapability() {
assertTrue(ControlInspectionService.rewardFileInventoryQuery(JsonParser.parseString(
assertTrue(ControlInspectionServiceAccess.rewardFileInventoryQuery(JsonParser.parseString(
"{\"kind\":\"reward-file-inventory\"}").getAsJsonObject()));
assertFalse(ControlInspectionService.rewardFileInventoryQuery(JsonParser.parseString(
assertFalse(ControlInspectionServiceAccess.rewardFileInventoryQuery(JsonParser.parseString(
"{\"kind\":\"overview\"}").getAsJsonObject()));
BackendControlConnector connector = org.mockito.Mockito.mock(BackendControlConnector.class,
org.mockito.Mockito.CALLS_REAL_METHODS);
assertEquals("UNAVAILABLE", connector.executeInspection(JsonParser.parseString(
assertEquals("UNAVAILABLE", BackendControlConnectorAccess.executeInspection(connector, JsonParser.parseString(
"{\"kind\":\"reward-file-inventory\"}").getAsJsonObject()).code());
}
@@ -445,15 +451,15 @@ class BackendControlConnectorProtocolTest {
org.mockito.Mockito.CALLS_REAL_METHODS);
var inspections = BackendControlConnector.class.getDeclaredField("inspections");
inspections.setAccessible(true);
inspections.set(connector, new ControlInspectionService());
inspections.set(connector, ControlInspectionServiceAccess.create());
for (String kind : List.of("null", "{}", "[]")) {
JsonObject query = JsonParser.parseString("{\"kind\":" + kind + ",\"filters\":{}}").getAsJsonObject();
assertEquals("VALIDATION_ERROR", connector.executeInspection(query).code(), kind);
assertEquals("VALIDATION_ERROR", BackendControlConnectorAccess.executeInspection(connector, query).code(), kind);
}
}
@Test void unexpectedInspectionFailureMessagesNeverExposeTheCause() {
String message = BackendControlConnector.inspectionFailureMessage(new IllegalStateException(
String message = BackendControlConnectorAccess.inspectionFailureMessage(new IllegalStateException(
"jdbc:mysql://database.internal/votes user=secret path=/srv/private"));
assertEquals("Inspection failed; see the backend log", message);
@@ -463,7 +469,7 @@ class BackendControlConnectorProtocolTest {
}
@Test void failureMessagesAreSingleLineAndBoundedBeforeSubmission() {
String message = BackendControlConnector.boundedResultMessage(
String message = BackendControlConnectorAccess.boundedResultMessage(
"unsupported field " + "x".repeat(1000) + "\r\nnext line");
assertTrue(message.length() <= 240);
@@ -471,7 +477,7 @@ class BackendControlConnectorProtocolTest {
assertFalse(message.contains("\n"));
assertTrue(message.endsWith("..."));
var changes = BackendControlConnector.boundedResultChanges(java.util.stream.IntStream.range(0, 25)
var changes = BackendControlConnectorAccess.boundedResultChanges(java.util.stream.IntStream.range(0, 25)
.mapToObj(index -> "change-" + index + "-" + "y".repeat(1000)).toList());
assertEquals(20, changes.size());
assertTrue(changes.stream().allMatch(change -> change.length() <= 240));
@@ -479,16 +485,16 @@ class BackendControlConnectorProtocolTest {
}
@Test void operationFailureCodesMatchTheRequestedAction() {
assertEquals("READ_FAILED", BackendControlConnector.operationFailureCode("READ"));
assertEquals("PREVIEW_FAILED", BackendControlConnector.operationFailureCode("PREVIEW"));
assertEquals("APPLY_FAILED", BackendControlConnector.operationFailureCode("APPLY"));
assertEquals("READ_FAILED", BackendControlConnectorAccess.operationFailureCode("READ"));
assertEquals("PREVIEW_FAILED", BackendControlConnectorAccess.operationFailureCode("PREVIEW"));
assertEquals("APPLY_FAILED", BackendControlConnectorAccess.operationFailureCode("APPLY"));
}
@Test void shutdownWaitsForTheClaimedBackendOperation() throws Exception {
var executor = Executors.newSingleThreadScheduledExecutor();
CompletableFuture<Void> operation = new CompletableFuture<>();
CompletableFuture<Void> closing = CompletableFuture.runAsync(
() -> BackendControlConnector.awaitShutdown(executor, operation));
() -> BackendControlConnectorAccess.awaitShutdown(executor, operation));
assertThrows(java.util.concurrent.TimeoutException.class, () -> closing.get(100, TimeUnit.MILLISECONDS));
operation.complete(null);
@@ -511,19 +517,19 @@ class BackendControlConnectorProtocolTest {
});
assertTrue(started.await(1, TimeUnit.SECONDS));
assertTrue(BackendControlConnector.awaitInspectionShutdown(executor));
assertTrue(BackendControlConnectorAccess.awaitInspectionShutdown(executor));
assertTrue(interrupted.get());
assertTrue(executor.isTerminated());
}
@Test void failedResultAcknowledgementDoesNotTriggerConnectorHandoff() throws Exception {
AtomicBoolean handedOff = new AtomicBoolean();
assertThrows(IOException.class, () -> BackendControlConnector.afterResultAcknowledged(
assertThrows(IOException.class, () -> BackendControlConnectorAccess.afterResultAcknowledged(
() -> { throw new IOException("Control result was not acknowledged"); },
() -> handedOff.set(true)));
assertFalse(handedOff.get());
BackendControlConnector.afterResultAcknowledged(() -> { }, () -> handedOff.set(true));
BackendControlConnectorAccess.afterResultAcknowledged(() -> { }, () -> handedOff.set(true));
assertTrue(handedOff.get());
}
}
@@ -0,0 +1,74 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration;
import com.google.gson.JsonObject;
/** Test-only forwarding access to the non-public production BackendControlResultStore. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class BackendControlResultStoreAccess implements ReflectiveAccess.View {
private final Object delegate;
private BackendControlResultStoreAccess(Object delegate) { this.delegate = delegate; }
public static BackendControlResultStoreAccess view(Object delegate) { return delegate == null ? null : new BackendControlResultStoreAccess(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof BackendControlResultStoreAccess value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public static BackendControlResultStoreAccess mock() { return view(org.mockito.Mockito.mock(ReflectiveAccess.type("com.bencodez.votingplugin.control.BackendControlResultStore"))); }
public BackendControlResultStoreAccess verify() { return view(org.mockito.Mockito.verify(delegate)); }
public BackendControlResultStoreAccess verify(org.mockito.verification.VerificationMode mode) { return view(org.mockito.Mockito.verify(delegate, mode)); }
public BackendControlResultStoreAccess stub(org.mockito.stubbing.Stubber stubber) { return view(stubber.when(delegate)); }
public static State load(Path dataDirectory) throws IOException { return BackendControlResultStoreAccess.State.view(ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore", null, "load", new String[]{"Path"}, new Object[]{dataDirectory})); }
public static void save(Path dataDirectory, Route route, HostConfiguration hosted, Map<UUID, StoredResult> results) throws IOException { ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore", null, "save", new String[]{"Path", "Route", "HostConfiguration", "Map"}, new Object[]{dataDirectory, route, hosted, results}); }
public static final class Route implements ReflectiveAccess.View {
public Route() { this(ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendControlResultStore$Route", new String[]{}, new Object[]{})); }
private final Object delegate;
private Route(Object delegate) { this.delegate = delegate; }
public static Route view(Object delegate) { return delegate == null ? null : new Route(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof Route value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public Route(String nodeId, URI endpoint, String credentialFile, int heartbeatSeconds, int connectTimeoutMillis, int requestTimeoutMillis) { this(ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendControlResultStore$Route", new String[]{"String", "URI", "String", "int", "int", "int"}, new Object[]{nodeId, endpoint, credentialFile, heartbeatSeconds, connectTimeoutMillis, requestTimeoutMillis})); }
public String nodeId() { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$Route", delegate, "nodeId", new String[]{}, new Object[]{}); }
public URI endpoint() { return (URI) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$Route", delegate, "endpoint", new String[]{}, new Object[]{}); }
public String credentialFile() { return (String) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$Route", delegate, "credentialFile", new String[]{}, new Object[]{}); }
public int heartbeatSeconds() { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$Route", delegate, "heartbeatSeconds", new String[]{}, new Object[]{}); }
public int connectTimeoutMillis() { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$Route", delegate, "connectTimeoutMillis", new String[]{}, new Object[]{}); }
public int requestTimeoutMillis() { return (Integer) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$Route", delegate, "requestTimeoutMillis", new String[]{}, new Object[]{}); }
}
public static final class StoredResult implements ReflectiveAccess.View {
public StoredResult() { this(ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendControlResultStore$StoredResult", new String[]{}, new Object[]{})); }
private final Object delegate;
private StoredResult(Object delegate) { this.delegate = delegate; }
public static StoredResult view(Object delegate) { return delegate == null ? null : new StoredResult(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof StoredResult value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public StoredResult(JsonObject result, boolean restartConnector, boolean committed, boolean claimRequired) { this(ReflectiveAccess.construct("com.bencodez.votingplugin.control.BackendControlResultStore$StoredResult", new String[]{"JsonObject", "boolean", "boolean", "boolean"}, new Object[]{result, restartConnector, committed, claimRequired})); }
public JsonObject result() { return (JsonObject) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$StoredResult", delegate, "result", new String[]{}, new Object[]{}); }
public boolean restartConnector() { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$StoredResult", delegate, "restartConnector", new String[]{}, new Object[]{}); }
public boolean committed() { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$StoredResult", delegate, "committed", new String[]{}, new Object[]{}); }
public boolean claimRequired() { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$StoredResult", delegate, "claimRequired", new String[]{}, new Object[]{}); }
}
public static final class State implements ReflectiveAccess.View {
private final Object delegate;
private State(Object delegate) { this.delegate = delegate; }
public static State view(Object delegate) { return delegate == null ? null : new State(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof State value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public Route route() { return BackendControlResultStoreAccess.Route.view(ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$State", delegate, "route", new String[]{}, new Object[]{})); }
public HostConfiguration hostedConfiguration() { return (HostConfiguration) ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$State", delegate, "hostedConfiguration", new String[]{}, new Object[]{}); }
public Map<UUID, StoredResult> results() { Object rawResult = ReflectiveAccess.call("com.bencodez.votingplugin.control.BackendControlResultStore$State", delegate, "results", new String[]{}, new Object[]{}); return rawResult == null ? null : ((java.util.Map<?, ?>) rawResult).entrySet().stream().collect(java.util.stream.Collectors.toMap(entry -> (UUID) entry.getKey(), entry -> BackendControlResultStoreAccess.StoredResult.view(entry.getValue()))); }
}
}
@@ -1,4 +1,7 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.BackendConfigurationService;
import com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -15,8 +18,8 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.bencodez.votingplugin.control.BackendControlResultStore.Route;
import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult;
import com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess.Route;
import com.bencodez.votingplugin.tests.control.BackendControlResultStoreAccess.StoredResult;
import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration;
import com.google.gson.JsonObject;
@@ -35,14 +38,14 @@ class BackendControlResultStoreTest {
Map<UUID, StoredResult> pending = new LinkedHashMap<>();
pending.put(operationId, new StoredResult(result, true, true, false));
BackendControlResultStore.save(directory, route, hosted, pending);
BackendControlResultStore.State recovered = BackendControlResultStore.load(directory);
BackendControlResultStoreAccess.save(directory, route, hosted, pending);
BackendControlResultStoreAccess.State recovered = BackendControlResultStoreAccess.load(directory);
assertEquals(route, recovered.route());
assertEquals(hosted, recovered.hostedConfiguration());
assertEquals("applied-revision", recovered.results().get(operationId).result().get("revision").getAsString());
assertTrue(recovered.results().get(operationId).committed());
BackendControlResultStore.save(directory, route, hosted, Map.of());
BackendControlResultStoreAccess.save(directory, route, hosted, Map.of());
assertFalse(Files.exists(directory.resolve(".control-pending-results.json")));
}
@@ -53,10 +56,10 @@ class BackendControlResultStoreTest {
JsonObject result = new JsonObject();
result.addProperty("revision", "anticipated-revision");
BackendControlResultStore.save(directory, route, hosted,
BackendControlResultStoreAccess.save(directory, route, hosted,
Map.of(operationId, new StoredResult(result, false, false, false)));
StoredResult recovered = BackendControlResultStore.load(directory).results().get(operationId);
StoredResult recovered = BackendControlResultStoreAccess.load(directory).results().get(operationId);
assertFalse(recovered.committed());
assertFalse(recovered.claimRequired());
}
@@ -85,7 +88,7 @@ class BackendControlResultStoreTest {
root.add("results", results);
Files.writeString(directory.resolve(".control-pending-results.json"), root.toString());
BackendControlResultStore.State recovered = BackendControlResultStore.load(directory);
BackendControlResultStoreAccess.State recovered = BackendControlResultStoreAccess.load(directory);
assertEquals(URI.create("https://control.example:8443"), recovered.route().endpoint());
assertFalse(recovered.hostedConfiguration().enabled());
@@ -97,7 +100,7 @@ class BackendControlResultStoreTest {
Files.writeString(external, "{}");
Files.createSymbolicLink(directory.resolve(".control-pending-results.json"), external);
assertThrows(java.io.IOException.class, () -> BackendControlResultStore.load(directory));
assertThrows(java.io.IOException.class, () -> BackendControlResultStoreAccess.load(directory));
}
@Test void maximumEscapeHeavyConfigurationFitsTheEncodedJournal() throws Exception {
@@ -111,12 +114,12 @@ class BackendControlResultStoreTest {
result.addProperty("success", true);
result.add("configuration", configuration);
BackendControlResultStore.save(directory, route, hosted,
BackendControlResultStoreAccess.save(directory, route, hosted,
Map.of(UUID.fromString("00000000-0000-0000-0000-000000000099"),
new StoredResult(result, false, true, false)));
assertEquals(BackendConfigurationService.MAX_CONTENT_BYTES,
BackendControlResultStore.load(directory).results().values().iterator().next().result()
BackendControlResultStoreAccess.load(directory).results().values().iterator().next().result()
.getAsJsonObject("configuration").get("content").getAsString().length());
}
}
@@ -0,0 +1,17 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import com.bencodez.votingplugin.control.ControlInspectionService.*;
import com.bencodez.votingplugin.VotingPluginMain;
import com.google.gson.JsonObject;
/** Typed test-only access to package-private ControlInspectionService seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class ControlInspectionServiceAccess {
public static final int MAX_ROWS = (Integer) ReflectiveAccess.field("com.bencodez.votingplugin.control.ControlInspectionService", "MAX_ROWS");
private ControlInspectionServiceAccess() { }
public static ControlInspectionService create(VotingPluginMain plugin) { return (ControlInspectionService) ReflectiveAccess.construct("com.bencodez.votingplugin.control.ControlInspectionService", new String[]{"VotingPluginMain"}, new Object[]{plugin}); }
public static ControlInspectionService create() { return (ControlInspectionService) ReflectiveAccess.construct("com.bencodez.votingplugin.control.ControlInspectionService", new String[]{}, new Object[]{}); }
public static boolean rewardFileInventoryQuery(JsonObject query) { return (Boolean) ReflectiveAccess.call("com.bencodez.votingplugin.control.ControlInspectionService", null, "rewardFileInventoryQuery", new String[]{"JsonObject"}, new Object[]{query}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.ControlInspectionService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -51,7 +53,7 @@ class ControlInspectionServiceTest {
when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
when(plugin.getBackendProxyHandler()).thenReturn(null);
when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(new org.bukkit.plugin.Plugin[0]);
JsonObject result = new ControlInspectionService(plugin).inspect(JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result");
JsonObject result = ControlInspectionServiceAccess.create(plugin).inspect(JsonParser.parseString("{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result");
assertEquals("REQUIRED", result.get("sharedAuthentication").getAsString());
String fixtureDir = System.getProperty("network.health.fixture.dir");
if (fixtureDir != null) {
@@ -69,13 +71,13 @@ class ControlInspectionServiceTest {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
when(plugin.getDataFolder()).thenReturn(directory.toFile());
JsonObject result = new ControlInspectionService(plugin).inspect(JsonParser.parseString(
JsonObject result = ControlInspectionServiceAccess.create(plugin).inspect(JsonParser.parseString(
"{\"kind\":\"reward-file-inventory\",\"filters\":{}}")
.getAsJsonObject()).getAsJsonObject("result");
assertEquals(List.of("Daily.yml"), result.getAsJsonArray("files").asList().stream()
.map(element -> element.getAsString()).toList());
assertTrue(ControlInspectionService.rewardFileInventoryQuery(JsonParser.parseString(
assertTrue(ControlInspectionServiceAccess.rewardFileInventoryQuery(JsonParser.parseString(
"{\"kind\":\"reward-file-inventory\"}").getAsJsonObject()));
}
@@ -90,21 +92,21 @@ class ControlInspectionServiceTest {
.mapToObj(index -> mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS)).toArray(org.bukkit.plugin.Plugin[]::new));
org.bukkit.plugin.Plugin[] plugins = plugin.getServer().getPluginManager().getPlugins();
for (int index = 0; index < plugins.length; index++) when(plugins[index].getName()).thenReturn("Plugin" + index);
JsonObject result = new ControlInspectionService(plugin).inspect(JsonParser.parseString(
JsonObject result = ControlInspectionServiceAccess.create(plugin).inspect(JsonParser.parseString(
"{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result");
assertFalse(result.has("detectedPlugins"));
org.bukkit.plugin.Plugin[] complete = new org.bukkit.plugin.Plugin[ControlInspectionService.MAX_ROWS];
org.bukkit.plugin.Plugin[] complete = new org.bukkit.plugin.Plugin[ControlInspectionServiceAccess.MAX_ROWS];
for (int index = 0; index < complete.length; index++) {
complete[index] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS);
when(complete[index].getName()).thenReturn("Complete" + index);
}
when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(complete);
result = new ControlInspectionService(plugin).inspect(JsonParser.parseString(
result = ControlInspectionServiceAccess.create(plugin).inspect(JsonParser.parseString(
"{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result");
assertEquals(ControlInspectionService.MAX_ROWS, result.getAsJsonArray("detectedPlugins").size());
complete[ControlInspectionService.MAX_ROWS - 1] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS);
when(complete[ControlInspectionService.MAX_ROWS - 1].getName()).thenReturn("bad\nname");
result = new ControlInspectionService(plugin).inspect(JsonParser.parseString(
assertEquals(ControlInspectionServiceAccess.MAX_ROWS, result.getAsJsonArray("detectedPlugins").size());
complete[ControlInspectionServiceAccess.MAX_ROWS - 1] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS);
when(complete[ControlInspectionServiceAccess.MAX_ROWS - 1].getName()).thenReturn("bad\nname");
result = ControlInspectionServiceAccess.create(plugin).inspect(JsonParser.parseString(
"{\"kind\":\"network-health\",\"filters\":{}}").getAsJsonObject()).getAsJsonObject("result");
assertFalse(result.has("detectedPlugins"));
}
@@ -117,7 +119,7 @@ class ControlInspectionServiceTest {
when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites);
when(plugin.getVoteSiteManager().getVoteSites()).thenReturn(new ArrayList<>());
when(plugin.getVoteLogMysqlTable()).thenReturn(null);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"overview\",\"filters\":{}}")
@@ -128,7 +130,7 @@ class ControlInspectionServiceTest {
}
@Test void rewardSimulationNormalizesButNeverExecutesTypedActions() {
ControlInspectionService service = new ControlInspectionService();
ControlInspectionService service = ControlInspectionServiceAccess.create();
JsonObject proposal = JsonParser.parseString("""
{"scope":"site","site":"PMC","commands":["eco give %player% 100"],
"playerMessages":["Thanks"],"items":[{"material":"diamond","amount":2}],
@@ -153,7 +155,7 @@ class ControlInspectionServiceTest {
}
@Test void inspectionContractRejectsUnknownKindsFieldsAndUnboundedSearches() {
ControlInspectionService service = new ControlInspectionService();
ControlInspectionService service = ControlInspectionServiceAccess.create();
assertThrows(IllegalArgumentException.class,
() -> service.inspect(JsonParser.parseString("{\"kind\":\"raw-sql\"}").getAsJsonObject()));
@@ -192,7 +194,7 @@ class ControlInspectionServiceTest {
}
@Test void rewardProposalRejectsUnknownActionsAndEmptyPlans() {
ControlInspectionService service = new ControlInspectionService();
ControlInspectionService service = ControlInspectionServiceAccess.create();
assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString(
"{\"scope\":\"site\",\"site\":\"PMC\",\"shell\":\"rm\"}").getAsJsonObject())));
assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString(
@@ -209,7 +211,7 @@ class ControlInspectionServiceTest {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
when(plugin.getConfigVoteSites().getData())
.thenReturn(new org.bukkit.configuration.file.YamlConfiguration());
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString(
"{\"scope\":\"site\",\"site\":\"Missing\",\"commands\":[\"say test\"]}"
@@ -220,7 +222,7 @@ class ControlInspectionServiceTest {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
VoteLogMysqlTable staleTable = mock(VoteLogMysqlTable.class);
when(plugin.getVoteLogMysqlTable()).thenReturn(staleTable);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject query = JsonParser.parseString(
"{\"kind\":\"vote-log-summary\",\"filters\":{\"days\":\"30\"}}")
.getAsJsonObject();
@@ -236,7 +238,7 @@ class ControlInspectionServiceTest {
when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true);
when(plugin.getVoteLogMysqlTable()).thenReturn(table);
when(table.isReadable()).thenReturn(false);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject query = JsonParser.parseString(
"{\"kind\":\"vote-log-summary\",\"filters\":{\"days\":\"30\"}}")
.getAsJsonObject();
@@ -258,7 +260,7 @@ class ControlInspectionServiceTest {
new ServiceCount("Zulu", 3), new ServiceCount("alpha", 3), new ServiceCount("Middle", 6)));
when(table.getTopServers(30, 20)).thenReturn(List.of(
new ServerCount("survival", 2), new ServerCount("Creative", 2), new ServerCount("hub", 8)));
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-log-summary\",\"filters\":{\"days\":\"30\"}}")
@@ -281,7 +283,7 @@ class ControlInspectionServiceTest {
@Test void exactPlayerMissDoesNotLoadOrEnumerateUsers() {
VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS);
when(plugin.getUserManager().userExist("ExactName")).thenReturn(false);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject query = JsonParser.parseString(
"{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}")
.getAsJsonObject();
@@ -306,7 +308,7 @@ class ControlInspectionServiceTest {
lastVotes.put(earlier, 100L);
when(user.getLastVotes()).thenReturn(lastVotes);
when(user.getLastVoteTime()).thenReturn(200L);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}")
@@ -348,7 +350,7 @@ class ControlInspectionServiceTest {
stored.put("MonthTotal-SMARCH-2025", new DataValueInt(95));
stored.put("MonthTotal-FEBRUARY-2025", new DataValueString("wrong type"));
when(user.getUserData().getValues()).thenReturn(stored);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}")
@@ -384,20 +386,20 @@ class ControlInspectionServiceTest {
when(user.getLastVotes()).thenReturn(new HashMap<>());
HashMap<String, DataValue> stored = new HashMap<>();
List<String> expected = new ArrayList<>();
for (int index = 0; index <= ControlInspectionService.MAX_ROWS; index++) {
for (int index = 0; index <= ControlInspectionServiceAccess.MAX_ROWS; index++) {
String name = "MonthTotal-" + java.time.Month.of(index % 12 + 1).name() + "-" + (2000 + index / 12);
stored.put(name, new DataValueInt(index));
expected.add(name);
}
expected.sort(String.CASE_INSENSITIVE_ORDER.thenComparing(java.util.Comparator.naturalOrder()));
when(user.getUserData().getValues()).thenReturn(stored);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}")
.getAsJsonObject()).getAsJsonObject("result");
assertEquals(expected.subList(0, ControlInspectionService.MAX_ROWS),
assertEquals(expected.subList(0, ControlInspectionServiceAccess.MAX_ROWS),
result.getAsJsonArray("columns").asList().stream()
.map(value -> value.getAsJsonObject().get("name").getAsString()).toList());
assertTrue(result.get("columnsTruncated").getAsBoolean());
@@ -414,17 +416,17 @@ class ControlInspectionServiceTest {
when(user.getOfflineVotes()).thenReturn(new ArrayList<>());
when(user.getLastVotes()).thenReturn(new HashMap<>());
HashMap<String, DataValue> stored = new HashMap<>();
for (int index = 0; index <= ControlInspectionService.MAX_ROWS; index++) {
for (int index = 0; index <= ControlInspectionServiceAccess.MAX_ROWS; index++) {
stored.put(String.format("VoteShopLimit%03d", index), new DataValueInt(index));
}
when(user.getUserData().getValues()).thenReturn(stored);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}")
.getAsJsonObject()).getAsJsonObject("result");
assertEquals(ControlInspectionService.MAX_ROWS, result.getAsJsonArray("columns").size());
assertEquals(ControlInspectionServiceAccess.MAX_ROWS, result.getAsJsonArray("columns").size());
assertEquals("VoteShopLimit000", result.getAsJsonArray("columns").get(0).getAsJsonObject()
.get("name").getAsString());
assertEquals("VoteShopLimit099", result.getAsJsonArray("columns").get(99).getAsJsonObject()
@@ -440,7 +442,7 @@ class ControlInspectionServiceTest {
when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites);
when(plugin.getVoteSiteManager().getVoteSites()).thenReturn(new ArrayList<>());
when(plugin.getVoteLogMysqlTable()).thenReturn(null);
org.bukkit.plugin.Plugin[] installed = new org.bukkit.plugin.Plugin[ControlInspectionService.MAX_ROWS + 1];
org.bukkit.plugin.Plugin[] installed = new org.bukkit.plugin.Plugin[ControlInspectionServiceAccess.MAX_ROWS + 1];
for (int index = 0; index < installed.length; index++) {
installed[index] = mock(org.bukkit.plugin.Plugin.class, RETURNS_DEEP_STUBS);
when(installed[index].getDescription().getName()).thenReturn(String.format("Plugin%03d", index));
@@ -448,13 +450,13 @@ class ControlInspectionServiceTest {
when(installed[99].getDescription().getName()).thenReturn("plugina");
when(installed[100].getDescription().getName()).thenReturn("PluginA");
when(plugin.getServer().getPluginManager().getPlugins()).thenReturn(installed);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"diagnostics\",\"filters\":{}}")
.getAsJsonObject()).getAsJsonObject("result");
assertEquals(ControlInspectionService.MAX_ROWS, result.getAsJsonArray("detectedPlugins").size());
assertEquals(ControlInspectionServiceAccess.MAX_ROWS, result.getAsJsonArray("detectedPlugins").size());
assertEquals("Plugin000", result.getAsJsonArray("detectedPlugins").get(0).getAsString());
assertEquals("PluginA", result.getAsJsonArray("detectedPlugins").get(99).getAsString());
assertTrue(result.get("detectedPluginsTruncated").getAsBoolean());
@@ -468,7 +470,7 @@ class ControlInspectionServiceTest {
when(plugin.getVoteLogMysqlTable()).thenReturn(null);
when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(java.util.List.of(
"NEW.example", "new.EXAMPLE", "configured.example"));
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}")
@@ -493,7 +495,7 @@ class ControlInspectionServiceTest {
when(plugin.getVoteLogMysqlTable()).thenReturn(table);
when(table.isReadable()).thenReturn(false);
when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(java.util.List.of());
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}")
@@ -523,7 +525,7 @@ class ControlInspectionServiceTest {
when(table.getServiceHealthForServices(30, List.of("configured.example"))).thenReturn(List.of(
new ServiceHealth("configured.example", 7, 500, 6, 1)));
when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of());
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}")
@@ -551,7 +553,7 @@ class ControlInspectionServiceTest {
new ServiceHealth(fullService.toUpperCase(Locale.ROOT), 5, 900, 4, 1)));
when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of(
fullService.toUpperCase(Locale.ROOT)));
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}")
@@ -583,7 +585,7 @@ class ControlInspectionServiceTest {
new ServiceHealth("configured-100.example", 3, 800, 3, 0)));
when(table.getServiceHealthForServices(30, displayed)).thenReturn(List.of());
when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of());
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}")
@@ -608,7 +610,7 @@ class ControlInspectionServiceTest {
new ServiceHealth("example.COM", 4, 900, 3, 1)));
when(table.getServiceHealthForServices(30, List.of("example.com"))).thenReturn(List.of());
when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of());
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}")
@@ -629,7 +631,7 @@ class ControlInspectionServiceTest {
.thenReturn(null);
when(plugin.getVoteSiteManager().resolveVoteSite("new.example", true)).thenReturn(null);
when(plugin.getConfigFile().isAutoCreateVoteSites()).thenReturn(true);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-resolution\",\"filters\":{\"serviceSite\":\"new.example\"}}")
@@ -654,7 +656,7 @@ class ControlInspectionServiceTest {
when(plugin.getVoteSiteManager().resolveVoteSite("PMC", true)).thenReturn(site);
when(plugin.getVoteSiteManager().hasVoteSite("PMC")).thenReturn(true);
when(plugin.getConfigFile().isAutoCreateVoteSites()).thenReturn(true);
ControlInspectionService service = new ControlInspectionService(plugin);
ControlInspectionService service = ControlInspectionServiceAccess.create(plugin);
JsonObject result = service.inspect(JsonParser.parseString(
"{\"kind\":\"vote-site-resolution\",\"filters\":{\"serviceSite\":\"planetminecraft.com\"}}")
@@ -0,0 +1,27 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import org.bukkit.configuration.file.FileConfiguration;
import com.google.gson.JsonObject;
/** Test-only forwarding access to the non-public production NetworkHealthConfigFacts. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class NetworkHealthConfigFactsAccess implements ReflectiveAccess.View {
private final Object delegate;
private NetworkHealthConfigFactsAccess(Object delegate) { this.delegate = delegate; }
public static NetworkHealthConfigFactsAccess view(Object delegate) { return delegate == null ? null : new NetworkHealthConfigFactsAccess(delegate); }
@Override public Object delegate() { return delegate; }
@Override public boolean equals(Object other) { return other instanceof NetworkHealthConfigFactsAccess value && delegate.equals(value.delegate); }
@Override public int hashCode() { return delegate.hashCode(); }
public static NetworkHealthConfigFactsAccess mock() { return view(org.mockito.Mockito.mock(ReflectiveAccess.type("com.bencodez.votingplugin.control.NetworkHealthConfigFacts"))); }
public NetworkHealthConfigFactsAccess verify() { return view(org.mockito.Mockito.verify(delegate)); }
public NetworkHealthConfigFactsAccess verify(org.mockito.verification.VerificationMode mode) { return view(org.mockito.Mockito.verify(delegate, mode)); }
public NetworkHealthConfigFactsAccess stub(org.mockito.stubbing.Stubber stubber) { return view(stubber.when(delegate)); }
public static void addBackend(JsonObject out, FileConfiguration config) { ReflectiveAccess.call("com.bencodez.votingplugin.control.NetworkHealthConfigFacts", null, "addBackend", new String[]{"JsonObject", "FileConfiguration"}, new Object[]{out, config}); }
public static void addSpecialRewards(JsonObject out, FileConfiguration config) { ReflectiveAccess.call("com.bencodez.votingplugin.control.NetworkHealthConfigFacts", null, "addSpecialRewards", new String[]{"JsonObject", "FileConfiguration"}, new Object[]{out, config}); }
public static void addGlobal(JsonObject out, FileConfiguration config) { ReflectiveAccess.call("com.bencodez.votingplugin.control.NetworkHealthConfigFacts", null, "addGlobal", new String[]{"JsonObject", "FileConfiguration"}, new Object[]{out, config}); }
public static void addDelay(JsonObject out, Object rawDelay, Object rawMinutes, com.bencodez.simpleapi.time.ParsedDuration parsed) { ReflectiveAccess.call("com.bencodez.votingplugin.control.NetworkHealthConfigFacts", null, "addDelay", new String[]{"JsonObject", "Object", "Object", "ParsedDuration"}, new Object[]{out, rawDelay, rawMinutes, parsed}); }
public static void addObservedServices(JsonObject out, Iterable<String> observations) { ReflectiveAccess.call("com.bencodez.votingplugin.control.NetworkHealthConfigFacts", null, "addObservedServices", new String[]{"JsonObject", "Iterable"}, new Object[]{out, observations}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.control.NetworkHealthConfigFactsAccess;
import static org.junit.jupiter.api.Assertions.*;
@@ -15,7 +17,7 @@ class NetworkHealthConfigFactsTest {
yaml.set("Database.Password", "secret");
yaml.set("BedrockPlayerPrefix", "x".repeat(81));
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addBackend(out, yaml);
NetworkHealthConfigFactsAccess.addBackend(out, yaml);
assertTrue(out.get("onlineMode").getAsBoolean());
assertFalse(out.has("maxVotesPerDay"));
assertFalse(out.has("Database.Password"));
@@ -32,8 +34,8 @@ class NetworkHealthConfigFactsTest {
special.set("VoteParty.VotesRequired", 12);
special.set("ResetMilestonesMonthly", false);
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addBackend(out, main);
NetworkHealthConfigFacts.addSpecialRewards(out, special);
NetworkHealthConfigFactsAccess.addBackend(out, main);
NetworkHealthConfigFactsAccess.addSpecialRewards(out, special);
assertFalse(out.get("votePartyEnabled").getAsBoolean());
assertEquals(12, out.get("votePartyVotesRequired").getAsInt());
assertFalse(out.get("resetMilestonesMonthly").getAsBoolean());
@@ -44,7 +46,7 @@ class NetworkHealthConfigFactsTest {
yaml.set("GlobalData.Prefix", "p".repeat(81));
yaml.set("GlobalData.UseMainMySQL", "yes");
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addGlobal(out, yaml);
NetworkHealthConfigFactsAccess.addGlobal(out, yaml);
assertFalse(out.has("globalDataPrefix"));
assertFalse(out.has("globalDataUseMainMysql"));
}
@@ -53,10 +55,10 @@ class NetworkHealthConfigFactsTest {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("TimeHourOffSet", Integer.MAX_VALUE);
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addBackend(out, yaml);
NetworkHealthConfigFactsAccess.addBackend(out, yaml);
assertFalse(out.has("timeHourOffset"));
yaml.set("TimeHourOffSet", -3);
NetworkHealthConfigFacts.addBackend(out, yaml);
NetworkHealthConfigFactsAccess.addBackend(out, yaml);
assertEquals(-3, out.get("timeHourOffset").getAsInt());
}
@@ -66,7 +68,7 @@ class NetworkHealthConfigFactsTest {
special.set("VoteParty.VotesRequired", "many");
special.set("ResetMilestonesMonthly", 1);
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addSpecialRewards(out, special);
NetworkHealthConfigFactsAccess.addSpecialRewards(out, special);
assertFalse(out.has("votePartyEnabled"));
assertFalse(out.has("votePartyVotesRequired"));
assertFalse(out.has("resetMilestonesMonthly"));
@@ -78,31 +80,31 @@ class NetworkHealthConfigFactsTest {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("TimeHourOffSet", Long.MIN_VALUE);
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addBackend(out, yaml);
NetworkHealthConfigFactsAccess.addBackend(out, yaml);
assertFalse(out.has("timeHourOffset"));
}
@Test void delayParserEmptyResultCannotProveMalformedTextValid() {
for (String text : java.util.List.of("abc", "-5h", "")) {
JsonObject out = new JsonObject();
NetworkHealthConfigFacts.addDelay(out, text, null, com.bencodez.simpleapi.time.ParsedDuration.parse(text, java.util.concurrent.TimeUnit.HOURS));
NetworkHealthConfigFactsAccess.addDelay(out, text, null, com.bencodez.simpleapi.time.ParsedDuration.parse(text, java.util.concurrent.TimeUnit.HOURS));
assertFalse(out.get("delayValid").getAsBoolean());
}
JsonObject halfHour = new JsonObject();
NetworkHealthConfigFacts.addDelay(halfHour, "30m", null, com.bencodez.simpleapi.time.ParsedDuration.parse("30m"));
NetworkHealthConfigFactsAccess.addDelay(halfHour, "30m", null, com.bencodez.simpleapi.time.ParsedDuration.parse("30m"));
assertTrue(halfHour.get("delayValid").getAsBoolean()); assertEquals(1, halfHour.get("delayHours").getAsInt());
JsonObject zero = new JsonObject();
NetworkHealthConfigFacts.addDelay(zero, "0", null, com.bencodez.simpleapi.time.ParsedDuration.empty());
NetworkHealthConfigFactsAccess.addDelay(zero, "0", null, com.bencodez.simpleapi.time.ParsedDuration.empty());
assertTrue(zero.get("delayValid").getAsBoolean()); assertEquals(0, zero.get("delayHours").getAsInt());
}
@Test void incompleteServiceObservationsAreOmittedInsteadOfEmpty() {
JsonObject invalid = new JsonObject();
NetworkHealthConfigFacts.addObservedServices(invalid, java.util.List.of("unmatched", "s".repeat(81)));
NetworkHealthConfigFactsAccess.addObservedServices(invalid, java.util.List.of("unmatched", "s".repeat(81)));
assertFalse(invalid.has("detectedServices"));
JsonObject large = new JsonObject();
NetworkHealthConfigFacts.addObservedServices(large, java.util.stream.IntStream.range(0, 101).mapToObj(i -> "site" + i).toList());
NetworkHealthConfigFactsAccess.addObservedServices(large, java.util.stream.IntStream.range(0, 101).mapToObj(i -> "site" + i).toList());
assertFalse(large.has("detectedServices"));
JsonObject valid = new JsonObject();
NetworkHealthConfigFacts.addObservedServices(valid, java.util.List.of("site", "SITE", "other"));
NetworkHealthConfigFactsAccess.addObservedServices(valid, java.util.List.of("site", "SITE", "other"));
assertEquals(2, valid.getAsJsonArray("detectedServices").size());
}
@@ -1,4 +1,8 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.OptionalVotifierDiagnostics;
import com.google.gson.JsonObject;
import java.lang.reflect.*;
@@ -47,7 +51,7 @@ class NuVotifierJarDiagnosticsTest {
String packageName = platform.substring(0, platform.indexOf('.'));
Object pm = mock(Class.forName("com.vexsoftware.votifier." + packageName + "." + sourceName, false, loader));
Class<?> base = pm.getClass().getSuperclass();
Field owner = base.getDeclaredField("plugin"); owner.setAccessible(true); owner.set(pm, provider);
Field owner = base.getDeclaredField("plugin"); owner.setAccessible(true); ReflectiveAccess.set(owner, pm, provider);
Field filter = base.getDeclaredField("serverFilter"); filter.setAccessible(true);
Class<?> backendApi = Class.forName("com.vexsoftware.votifier.platform.BackendServer", false, loader);
Object backend = mock(backendApi);
@@ -57,31 +61,31 @@ class NuVotifierJarDiagnosticsTest {
Constructor<?> filterCtor = Class.forName("com.vexsoftware.votifier.support.forwarding.ServerFilter", false, loader)
.getConstructor(Collection.class, boolean.class);
setSource(provider, pm);
filter.set(pm, filterCtor.newInstance(List.of("backend-a"), true));
ReflectiveAccess.set(filter, pm, ReflectiveAccess.newInstance(filterCtor, new Object[]{List.of("backend-a"), true}));
assertTrue(facts(provider).get("votifierForwardingEnabled").getAsBoolean());
assertEquals("backend-a", facts(provider).getAsJsonArray("forwardingDestinations").get(0).getAsString());
filter.set(pm, filterCtor.newInstance(List.of(), true));
ReflectiveAccess.set(filter, pm, ReflectiveAccess.newInstance(filterCtor, new Object[]{List.of(), true}));
assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean());
filter.set(pm, filterCtor.newInstance(List.of("backend-a"), false));
ReflectiveAccess.set(filter, pm, ReflectiveAccess.newInstance(filterCtor, new Object[]{List.of("backend-a"), false}));
assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean());
if (sourceName.startsWith("OnlineForward")) {
Field fallback = pm.getClass().getDeclaredField("fallbackServer"); fallback.setAccessible(true);
when(proxyApi.getMethod("getServer", String.class).invoke(provider, "backend-a"))
.thenReturn(Optional.of(backend));
fallback.set(pm, "backend-a");
ReflectiveAccess.set(fallback, pm, "backend-a");
// Both an empty whitelist and a rejecting blacklist still allow the fallback.
for (boolean whitelist : List.of(true, false)) {
filter.set(pm, filterCtor.newInstance(whitelist ? List.of() : List.of("backend-a"), whitelist));
ReflectiveAccess.set(filter, pm, ReflectiveAccess.newInstance(filterCtor, new Object[]{whitelist ? List.of() : List.of("backend-a"), whitelist}));
assertTrue(facts(provider).get("votifierForwardingEnabled").getAsBoolean());
assertEquals("backend-a", facts(provider).getAsJsonArray("forwardingDestinations").get(0).getAsString());
}
when(proxyApi.getMethod("getServer", String.class).invoke(provider, "backend-a"))
.thenReturn(Optional.empty());
assertFalse(facts(provider).get("votifierForwardingEnabled").getAsBoolean());
fallback.set(pm, "bad\nname"); assertUnknown(provider);
fallback.set(pm, null);
ReflectiveAccess.set(fallback, pm, "bad\nname"); assertUnknown(provider);
ReflectiveAccess.set(fallback, pm, null);
}
filter.set(pm, null); assertUnknown(provider);
ReflectiveAccess.set(filter, pm, null); assertUnknown(provider);
}
}
}
@@ -92,13 +96,12 @@ class NuVotifierJarDiagnosticsTest {
Constructor<?> backend = Class.forName(source.getName() + "$BackendServer", false, loader)
.getConstructor(String.class, InetSocketAddress.class, java.security.Key.class);
List<Object> targets = new ArrayList<>();
for (String name : names) targets.add(backend.newInstance(name,
InetSocketAddress.createUnresolved("not-exported.invalid", 8192), null));
for (String name : names) targets.add(ReflectiveAccess.newInstance(backend, new Object[]{name, InetSocketAddress.createUnresolved("not-exported.invalid", 8192), null}));
Constructor<?> ctor = Arrays.stream(source.getConstructors()).filter(c -> c.getParameterCount() == 4).findFirst().orElseThrow();
return ctor.newInstance(null, null, targets, null); // stores fields; no start/forward call
return ReflectiveAccess.newInstance(ctor, new Object[]{null, null, targets, null}); // stores fields; no start/forward call
}
private static void setSource(Object provider, Object source) throws Exception {
Field field = provider.getClass().getDeclaredField("forwardingMethod"); field.setAccessible(true); field.set(provider, source);
Field field = provider.getClass().getDeclaredField("forwardingMethod"); field.setAccessible(true); ReflectiveAccess.set(field, provider, source);
}
private static JsonObject facts(Object provider) { return facts(List.of(provider)); }
private static JsonObject facts(List<?> providers) {
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.OptionalVotifierDiagnostics;
import com.google.gson.JsonObject;
import java.util.*;
import org.junit.jupiter.api.Test;
@@ -0,0 +1,17 @@
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.tests.support.ReflectiveAccess;
import com.bencodez.votingplugin.control.*;
import com.bencodez.votingplugin.control.PluginDeploymentService.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.function.BooleanSupplier;
/** Typed test-only access to package-private PluginDeploymentService seams. */
@SuppressWarnings({"unchecked", "rawtypes"})
public final class PluginDeploymentServiceAccess {
private PluginDeploymentServiceAccess() { }
public static Result stage(PluginDeploymentService target, Task task, InputStream body, BooleanSupplier active) throws IOException { return (Result) ReflectiveAccess.call("com.bencodez.votingplugin.control.PluginDeploymentService", target, "stage", new String[]{"Task", "InputStream", "BooleanSupplier"}, new Object[]{task, body, active}); }
public static void move(Path source, Path destination) throws IOException { ReflectiveAccess.call("com.bencodez.votingplugin.control.PluginDeploymentService", null, "move", new String[]{"Path", "Path"}, new Object[]{source, destination}); }
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.control;
package com.bencodez.votingplugin.tests.control;
import com.bencodez.votingplugin.control.PluginDeploymentService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@@ -43,7 +45,7 @@ class PluginDeploymentServiceTest {
java.util.Map.of("create", "true"))) {
Path destination = zip.getPath("/VotingPlugin.jar");
assertThrows(AtomicMoveNotSupportedException.class,
() -> PluginDeploymentService.move(source, destination));
() -> PluginDeploymentServiceAccess.move(source, destination));
assertArrayEquals(original, Files.readAllBytes(source));
assertFalse(Files.exists(destination));
}
@@ -54,12 +56,12 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar"));
PluginDeploymentService.Task task = task(artifact);
PluginDeploymentService.Result staged = service.stage(task, new ByteArrayInputStream(artifact), () -> true);
PluginDeploymentService.Result staged = PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true);
assertTrue(staged.success());
assertEquals("RESTART_REQUIRED", staged.code());
assertEquals(artifact.length, Files.size(directory.resolve("update/VotingPlugin.jar")));
assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(new byte[0]), () -> true).success());
}
@Test void backendStagesUsingTheInstalledPluginJarFileName() throws Exception {
@@ -68,7 +70,7 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(update,
directory.resolve("plugins/VotingPlugin-7.1.2.jar"));
assertEquals("RESTART_REQUIRED", service.stage(task(artifact), new ByteArrayInputStream(artifact), () -> true).code());
assertEquals("RESTART_REQUIRED", PluginDeploymentServiceAccess.stage(service, task(artifact), new ByteArrayInputStream(artifact), () -> true).code());
assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin-7.1.2.jar")));
assertFalse(Files.exists(update.resolve("VotingPlugin.jar")));
}
@@ -86,11 +88,11 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(update, installed);
PluginDeploymentService.Task task = task(artifact);
assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true).success());
Files.write(installed, artifact);
Files.delete(update.resolve("VotingPlugin.jar"));
assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(new byte[0]), () -> true).success());
assertFalse(Files.exists(update.resolve("VotingPlugin.jar")),
"a lost acknowledgement after restart must recognize the artifact Bukkit already consumed");
}
@@ -103,17 +105,17 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(update, installed);
PluginDeploymentService.Task original = task(artifact);
PluginDeploymentService.Task retry = task(artifact);
assertTrue(service.stage(original, new ByteArrayInputStream(artifact), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, original, new ByteArrayInputStream(artifact), () -> true).success());
assertFalse(original.deploymentId().equals(retry.deploymentId()));
assertEquals("RESTART_REQUIRED",
service.stage(retry, new ByteArrayInputStream(new byte[0]), () -> true).code());
PluginDeploymentServiceAccess.stage(service, retry, new ByteArrayInputStream(new byte[0]), () -> true).code());
assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin.jar")));
Files.write(installed, artifact);
Files.delete(update.resolve("VotingPlugin.jar"));
assertEquals("RESTART_REQUIRED",
service.stage(retry, new ByteArrayInputStream(new byte[0]), () -> true).code());
PluginDeploymentServiceAccess.stage(service, retry, new ByteArrayInputStream(new byte[0]), () -> true).code());
assertFalse(Files.exists(update.resolve("VotingPlugin.jar")));
}
@@ -125,10 +127,10 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(update, installed);
PluginDeploymentService.Task task = task(artifact);
assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true).success());
Files.delete(update.resolve("VotingPlugin.jar"));
assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true).success());
assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin.jar")),
"a deleted or quarantined update must be staged again before restart");
}
@@ -201,7 +203,7 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar"));
PluginDeploymentService.Task task = task(artifact);
service.stage(task, new ByteArrayInputStream(artifact), () -> true);
PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true);
assertArrayEquals(artifact, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")));
byte[] corrupted = new byte[artifact.length];
@@ -209,14 +211,14 @@ class PluginDeploymentServiceTest {
Files.write(directory.resolve("update/VotingPlugin.jar"), corrupted);
assertArrayEquals(corrupted, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")));
assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success());
assertTrue(PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true).success());
assertArrayEquals(artifact, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")));
}
@Test void invalidPluginIdentityAndDigestNeverReachTheUpdateFolder() throws Exception {
byte[] wrongPlugin = jar("name: NotVotingPlugin\n");
PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar"));
PluginDeploymentService.Result invalid = service.stage(task(wrongPlugin), new ByteArrayInputStream(wrongPlugin), () -> true);
PluginDeploymentService.Result invalid = PluginDeploymentServiceAccess.stage(service, task(wrongPlugin), new ByteArrayInputStream(wrongPlugin), () -> true);
assertFalse(invalid.success());
assertEquals("INVALID_ARTIFACT", invalid.code());
assertFalse(Files.exists(directory.resolve("update/VotingPlugin.jar")));
@@ -224,7 +226,7 @@ class PluginDeploymentServiceTest {
byte[] good = jar("name: VotingPlugin\n");
PluginDeploymentService.Task mismatch = new PluginDeploymentService.Task(UUID.randomUUID(), "vp.jar",
"0".repeat(64), good.length, UUID.randomUUID());
assertEquals("HASH_MISMATCH", service.stage(mismatch, new ByteArrayInputStream(good), () -> true).code());
assertEquals("HASH_MISMATCH", PluginDeploymentServiceAccess.stage(service, mismatch, new ByteArrayInputStream(good), () -> true).code());
}
@Test void proxyKeepsTheCurrentJarAsABackupBeforeReplacement() throws Exception {
@@ -235,8 +237,8 @@ class PluginDeploymentServiceTest {
Files.write(current, original);
PluginDeploymentService service = PluginDeploymentService.proxy(current);
assertEquals("RESTART_REQUIRED", service.stage(task(first), new ByteArrayInputStream(first), () -> true).code());
assertEquals("RESTART_REQUIRED", service.stage(task(second), new ByteArrayInputStream(second), () -> true).code());
assertEquals("RESTART_REQUIRED", PluginDeploymentServiceAccess.stage(service, task(first), new ByteArrayInputStream(first), () -> true).code());
assertEquals("RESTART_REQUIRED", PluginDeploymentServiceAccess.stage(service, task(second), new ByteArrayInputStream(second), () -> true).code());
assertArrayEquals(first, Files.readAllBytes(directory.resolve("VotingPlugin.jar.control-backup")));
assertArrayEquals(second, Files.readAllBytes(current));
}
@@ -253,11 +255,11 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.proxy(current);
assertEquals("SIZE_MISMATCH",
service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code());
PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(new byte[0]), () -> true).code());
assertFalse(Files.exists(marker));
assertArrayEquals(original, Files.readAllBytes(backup));
assertEquals("RESTART_REQUIRED",
service.stage(task, new ByteArrayInputStream(candidate), () -> true).code());
PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(candidate), () -> true).code());
assertArrayEquals(candidate, Files.readAllBytes(current));
assertArrayEquals(candidate, Files.readAllBytes(backup));
@@ -275,10 +277,10 @@ class PluginDeploymentServiceTest {
Files.write(current, original);
PluginDeploymentService service = PluginDeploymentService.proxy(current);
PluginDeploymentService.Task task = task(candidate);
assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(candidate), () -> true).code());
assertEquals("RESTART_REQUIRED", PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(candidate), () -> true).code());
Files.delete(backup);
assertEquals("SIZE_MISMATCH", service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code());
assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(candidate), () -> true).code());
assertEquals("SIZE_MISMATCH", PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(new byte[0]), () -> true).code());
assertEquals("RESTART_REQUIRED", PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(candidate), () -> true).code());
assertTrue(Files.isRegularFile(backup));
}
@@ -287,7 +289,7 @@ class PluginDeploymentServiceTest {
PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar"));
PluginDeploymentService.Task task = task(artifact);
assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(artifact), () -> true).code());
assertEquals("RESTART_REQUIRED", PluginDeploymentServiceAccess.stage(service, task, new ByteArrayInputStream(artifact), () -> true).code());
String markerBefore = Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment"));
byte[] targetBefore = Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"));
@@ -295,7 +297,7 @@ class PluginDeploymentServiceTest {
byte[] replacementArtifact = jar("name: VotingPlugin\nversion: replacement\n");
InputStream body = new CancellingInputStream(new ByteArrayInputStream(replacementArtifact), active);
PluginDeploymentService.Task replacement = task(replacementArtifact);
assertEquals("CANCELLED", service.stage(replacement, body, active::get).code());
assertEquals("CANCELLED", PluginDeploymentServiceAccess.stage(service, replacement, body, active::get).code());
assertEquals(markerBefore, Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment")));
assertArrayEquals(targetBefore, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")));
@@ -314,7 +316,7 @@ class PluginDeploymentServiceTest {
Files.writeString(marker.resolve("keep"), "marker publication must fail");
PluginDeploymentService service = PluginDeploymentService.backend(update, Path.of("VotingPlugin.jar"));
assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true));
assertThrows(IOException.class, () -> PluginDeploymentServiceAccess.stage(service, task(candidate), new ByteArrayInputStream(candidate), () -> true));
assertArrayEquals(original, Files.readAllBytes(target));
}
@@ -329,7 +331,7 @@ class PluginDeploymentServiceTest {
Files.writeString(marker.resolve("keep"), "marker publication must fail");
PluginDeploymentService service = PluginDeploymentService.proxy(target);
assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true));
assertThrows(IOException.class, () -> PluginDeploymentServiceAccess.stage(service, task(candidate), new ByteArrayInputStream(candidate), () -> true));
assertArrayEquals(original, Files.readAllBytes(target));
assertArrayEquals(original, Files.readAllBytes(directory.resolve("VotingPlugin.jar.control-backup")));
@@ -341,7 +343,7 @@ class PluginDeploymentServiceTest {
BlockingInputStream stalled = new BlockingInputStream(active);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<PluginDeploymentService.Result> result = executor.submit(() -> service.stage(
Future<PluginDeploymentService.Result> result = executor.submit(() -> PluginDeploymentServiceAccess.stage(service,
task(new byte[] { 1 }), stalled, active::get));
assertTrue(stalled.awaitRead());
service.cancel();
@@ -1,4 +1,4 @@
package com.bencodez.votingplugin.core;
package com.bencodez.votingplugin.tests.core;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.core.maintenance;
package com.bencodez.votingplugin.tests.core.maintenance;
import com.bencodez.votingplugin.core.maintenance.MaintenanceConfirmation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.atomic.AtomicLong;
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.core.vote;
package com.bencodez.votingplugin.tests.core.vote;
import com.bencodez.votingplugin.core.vote.SharedVoteDelivery;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -1,4 +1,7 @@
package com.bencodez.votingplugin.core.vote;
package com.bencodez.votingplugin.tests.core.vote;
import com.bencodez.votingplugin.core.vote.SharedVotePolicy;
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more