From 66d1c858c7a3b09bd1e75a0c5d4f490eb032db6d Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 29 Oct 2026 17:21:59 -0600 Subject: [PATCH 1/3] fix(cooldown): retain deferred reward identity and failure context --- .../votingplugin/cooldown/CoolDownCheck.java | 160 ++++++++++-- .../cooldown/CoolDownBulkDispatchTest.java | 169 +++++++++++++ .../CoolDownIdentitySnapshotTest.java | 83 +++++++ .../cooldown/CoolDownMultipleSitesTest.java | 54 +++++ .../cooldown/CoolDownPendingCacheTest.java | 72 ++++++ .../CoolDownRewardDiagnosticsTest.java | 94 +++++++ .../CoolDownRewardIntegrationTest.java | 229 ++++++++++++++++++ .../cooldown/CoolDownTimerGenerationTest.java | 119 +++++++++ .../cooldown/CoolDownTimerRefreshTest.java | 70 ++++++ docs/cooldown-reward-reconciliation.md | 13 + 10 files changed, 1042 insertions(+), 21 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownBulkDispatchTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownIdentitySnapshotTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownMultipleSitesTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownPendingCacheTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardDiagnosticsTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerRefreshTest.java create mode 100644 docs/cooldown-reward-reconciliation.md diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java index de0140bc09..9376cd5da9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java @@ -11,10 +11,13 @@ import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserDataFetchMode; @@ -36,12 +39,14 @@ public class CoolDownCheck implements Listener { private final VotingPluginMain plugin; private final Map> perSiteTasks = new ConcurrentHashMap<>(); + private final Map perSiteGenerations = new ConcurrentHashMap<>(); private final Map> allSiteTasks = new ConcurrentHashMap<>(); @Getter private final ScheduledExecutorService timer; private volatile boolean cooldownCheckEnabled = false; + private volatile boolean stopped = false; /** * Constructs a new cooldown check manager. @@ -74,6 +79,8 @@ public void check(UUID uuid) { * Shuts down the cooldown check timer. */ public void shutdown() { + stopped = true; + perSiteGenerations.clear(); timer.shutdownNow(); for (ScheduledFuture f : perSiteTasks.values()) { @@ -147,6 +154,7 @@ public void checkAllVoteSite(VoteSite site) { user.updateTempCacheWithColumns(cols); cols.clear(); // help GC (only useful if reused) } + capturePlayerName(user); checkPerSite(site, user); } finally { @@ -219,13 +227,20 @@ public synchronized void checkPerSite(VotingPluginUser user) { * Site-specific version used by batch checks / checkAllVoteSite. */ private void checkPerSite(VoteSite site, VotingPluginUser user) { + checkPerSite(site, user, null); + } + + private HashMap checkPerSite(VoteSite site, VotingPluginUser user, + HashMap coolDownChecks) { if (!plugin.getConfigFile().isPerSiteCoolDownEvents() || site == null) { - return; + return coolDownChecks; } - HashMap coolDownChecks = user.getCoolDownCheckSiteList(); + if (coolDownChecks == null) { + coolDownChecks = user.getCoolDownCheckSiteList(); + } if (Boolean.TRUE.equals(coolDownChecks.get(site.getKey()))) { - return; + return coolDownChecks; } if (user.canVoteSite(site)) { @@ -237,6 +252,7 @@ private void checkPerSite(VoteSite site, VotingPluginUser user) { PlayerVoteSiteCoolDownEndEvent event = new PlayerVoteSiteCoolDownEndEvent(user, site); plugin.getServer().getPluginManager().callEvent(event); } + return coolDownChecks; } /** @@ -267,8 +283,8 @@ public void onStartUp(AdvancedCoreUser advancedcoreUser) { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) { - plugin.getRewardHandler().giveReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(), - "VoteCoolDownEndedReward", new RewardOptions()); + dispatchCooldownReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(), + "VoteCoolDownEndedReward", "all", new RewardOptions()); } /** @@ -278,11 +294,73 @@ public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onCoolDownEnd(PlayerVoteSiteCoolDownEndEvent event) { - plugin.getRewardHandler().giveReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards", - new RewardOptions().addPlaceholder("sitename", event.getSite().getDisplayName()) + dispatchCooldownReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards", + "site:" + event.getSite().getKey(), new RewardOptions() + .addPlaceholder("sitename", event.getSite().getDisplayName()) .addPlaceholder("url", event.getSite().getVoteURL(false))); } + private void dispatchCooldownReward(VotingPluginUser user, ConfigurationSection data, String path, + String site, RewardOptions options) { + boolean configured = false; + try { + configured = hasConfiguredCooldownReward(data, path); + } catch (RuntimeException ignored) { + // Preserve legacy dispatch even when diagnostic configuration inspection fails. + } + if (!configured) { + plugin.getRewardHandler().giveReward(user, data, path, options); + return; + } + String occurrence = UUID.randomUUID().toString(); + String player = user.getUUID(); + String safeSite = String.valueOf(site).replaceAll("[\\r\\n\\p{Cntrl}]", "?"); + long time = System.currentTimeMillis(); + String context = " occurrence=" + occurrence + " player=" + player + " site=" + safeSite + " time=" + time; + logCooldownDiagnostic(false, "Cooldown reward status=attempted" + context); + options.setAsyncFailureObserver(failure -> logCooldownFailure(context, failure)); + try { + plugin.getRewardHandler().giveReward(user, data, path, options); + } catch (RuntimeException | Error failure) { + logCooldownFailure(context, failure); + throw failure; + } + } + + private boolean hasConfiguredCooldownReward(ConfigurationSection data, String path) { + if (data == null) return false; + if (data instanceof FileConfiguration) { + return plugin.getRewardHandler().hasRewards((FileConfiguration) data, path); + } + if (data.isList(path)) return !data.getList(path, new ArrayList<>()).isEmpty(); + if (data.isConfigurationSection(path)) return !data.getConfigurationSection(path).getKeys(false).isEmpty(); + return data.isString(path) && !data.getString(path, "").isEmpty(); + } + + private void logCooldownFailure(String context, Throwable failure) { + int completed = -1; + Throwable current = failure; + for (int depth = 0; current != null && depth < 32; depth++, current = current.getCause()) { + if (current instanceof Reward.RewardReplayFailure) { + completed = ((Reward.RewardReplayFailure) current).getCompletedInjectionCount(); + break; + } + } + String error = failure == null ? "unknown" : failure.getClass().getSimpleName(); + logCooldownDiagnostic(true, "Cooldown reward outcome=unknown/partial" + context + " error=" + error + + " completedInjections=" + (completed < 0 ? "unknown" : completed) + + "; inspect effects before reissuing"); + } + + private void logCooldownDiagnostic(boolean warning, String message) { + try { + if (warning) plugin.getLogger().warning(message); + else plugin.getLogger().info(message); + } catch (RuntimeException ignored) { + // A broken logger must not suppress or retry the reward. + } + } + /** * Schedules a cooldown check for a user. * @@ -324,7 +402,7 @@ public void schedule(VotingPluginUser user, boolean force) { * @param force whether to force the schedule */ public void schedulePerSite(VotingPluginUser user, boolean force) { - if (!plugin.getConfigFile().isPerSiteCoolDownEvents()) { + if (stopped || !plugin.getConfigFile().isPerSiteCoolDownEvents()) { return; } @@ -333,6 +411,7 @@ public void schedulePerSite(VotingPluginUser user, boolean force) { } final UUID uuid = user.getJavaUUID(); + final String scheduledName = user.getPlayerName(); final NextSite next = user.getNextSiteAvailable(); @@ -340,8 +419,13 @@ public void schedulePerSite(VotingPluginUser user, boolean force) { if (existing != null) { existing.cancel(false); } + if (stopped) { + perSiteGenerations.remove(uuid); + return null; + } if (next == null || next.getSite() == null || next.getSecondsUntilAvailable() <= 0) { + perSiteGenerations.remove(uuid); plugin.extraDebug(user.getUUID() + "/" + user.getPlayerName() + " not scheduling per-site cooldown check"); return null; // remove entry } @@ -349,18 +433,42 @@ public void schedulePerSite(VotingPluginUser user, boolean force) { plugin.devDebug("PerSiteCoolDownEvent schedule: site=" + next.getSite().getKey() + " time=" + next.getSecondsUntilAvailable() + " seconds"); - return timer.schedule(() -> { - if (!plugin.isEnabled()) { - return; - } - - // Only check the next site; this will set the cooldown-end flag + fire event if ready - checkPerSite(next.getSite(), user); - - // Schedule the following site (if any) - schedulePerSite(user, false); - - }, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS); + Object generation = new Object(); + perSiteGenerations.put(uuid, generation); + try { + return timer.schedule(() -> { + if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) { + return; + } + UUID occurrence = UUID.randomUUID(); + try { + // The batch user's temporary row may have been cleared or replaced by a newer vote. + VotingPluginUser current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + if (!current.isCached()) { + current.userDataFetechMode(UserDataFetchMode.NO_CACHE); + } + String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode()); + String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName; + if (name != null && !name.isBlank()) { + current.setPlayerName(name); + } + if (perSiteGenerations.get(uuid) != generation) return; + checkPerSite(next.getSite(), current); + if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false); + } catch (RuntimeException | Error failure) { + String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?"); + logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid + + " site=" + siteKey + " time=" + System.currentTimeMillis() + " error=" + + failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing"); + throw failure; + } finally { + perSiteGenerations.remove(uuid, generation); + } + }, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS); + } catch (RuntimeException | Error failure) { + perSiteGenerations.remove(uuid, generation); + throw failure; + } }); } @@ -381,6 +489,14 @@ public void vote(VotingPluginUser user, VoteSite site) { } } + /** Preserve the name needed by asynchronous rewards after the temporary row is cleared. */ + private static void capturePlayerName(VotingPluginUser user) { + String name = user.getPlayerName(); + if (name != null && !name.isBlank()) { + user.setPlayerName(name); + } + } + /** * Performance-optimized: scan users ONCE, process all daily-delay sites. */ @@ -421,9 +537,11 @@ public void checkAllVoteSites() { user.updateTempCacheWithColumns(cols); cols.clear(); } + capturePlayerName(user); + HashMap coolDownChecks = null; for (int i = 0; i < dailySites.size(); i++) { - checkPerSite(dailySites.get(i), user); + coolDownChecks = checkPerSite(dailySites.get(i), user, coolDownChecks); } check(user); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownBulkDispatchTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownBulkDispatchTest.java new file mode 100644 index 0000000000..5e0354a152 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownBulkDispatchTest.java @@ -0,0 +1,169 @@ +package com.bencodez.votingplugin.tests.cooldown; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.config.SpecialRewardsConfig; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent; +import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.bencodez.votingplugin.votesites.VoteSiteManager; + +class CoolDownBulkDispatchTest { + private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123"); + + @Test void singleSiteScanKeepsIdentityUntilDeferredRewardRuns() throws Exception { exercise(false, "OfflinePlayer", null, "OfflinePlayer"); } + @Test void allSitesScanKeepsIdentityUntilDeferredRewardRuns() throws Exception { exercise(true, "OfflinePlayer", null, "OfflinePlayer"); } + @Test void absentNameStillDispatchesTheEvent() throws Exception { exercise(false, null, null, ""); } + @Test void explicitNameSurvivesCachedNameAndClear() throws Exception { exercise(true, "OldName", "OnlineName", "OnlineName"); } + + @Test void uuidEntryPointsUseExistingUserAndOfflineFetchMode() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteSite site = mock(VoteSite.class); + Config config = mock(Config.class); + VoteSiteManager sites = mock(VoteSiteManager.class); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE)).thenReturn(user); + when(user.isOnline()).thenReturn(false); + when(user.getCoolDownCheck()).thenReturn(true); + when(user.canVoteAll()).thenReturn(true); + when(user.getJavaUUID()).thenReturn(UUID_VALUE); + when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>()); + when(user.canVoteSite(site)).thenReturn(true); + when(site.getKey()).thenReturn("site"); + when(plugin.getConfigFile()).thenReturn(config); + when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites); + when(sites.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site))); + CoolDownCheck check = new CoolDownCheck(plugin); + try { + check.check(UUID_VALUE); + check.checkPerSite(UUID_VALUE); + verify(plugin.getVotingPluginUserManager(), times(2)).getVotingPluginUser(UUID_VALUE); + verify(user, times(2)).userDataFetechMode(UserDataFetchMode.NO_CACHE); + verify(plugin.getServer().getPluginManager()).callEvent(isA(PlayerVoteCoolDownEndEvent.class)); + verify(plugin.getServer().getPluginManager()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class)); + verify(user, never()).setPlayerName(anyString()); + } finally { + check.shutdown(); + } + } + + private void exercise(boolean allSites, String cachedName, String explicitName, String expected) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Config config = mock(Config.class); + VoteSiteManager sites = mock(VoteSiteManager.class); + VoteSite site = mock(VoteSite.class); + RewardHandler rewards = mock(RewardHandler.class); + SpecialRewardsConfig special = mock(SpecialRewardsConfig.class); + Server server = mock(Server.class); + PluginManager events = mock(PluginManager.class); + YamlConfiguration data = new YamlConfiguration(); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile()).thenReturn(config); + when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getSpecialRewardsConfig()).thenReturn(special); + when(special.getData()).thenReturn(data); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(events); + when(site.getKey()).thenReturn("site"); + when(site.getDisplayName()).thenReturn("Site"); + when(site.getVoteURL(false)).thenReturn("https://example.test"); + when(site.getSiteData()).thenReturn(data); + when(site.isVoteDelayDaily()).thenReturn(true); + when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site))); + + VotingPluginUser user = mock(VotingPluginUser.class); + Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + Field dataField = AdvancedCoreUser.class.getDeclaredField("data"); + dataField.setAccessible(true); + UserData userData = new UserData(user); + dataField.set(user, userData); + when(user.getUserData()).thenReturn(userData); + when(user.getPlugin()).thenReturn(plugin); + doCallRealMethod().when(user).getPlayerName(); + doCallRealMethod().when(user).setPlayerName(anyString()); + doCallRealMethod().when(user).userDataFetechMode(any(UserDataFetchMode.class)); + doCallRealMethod().when(user).updateTempCacheWithColumns(any()); + doCallRealMethod().when(user).clearTempCache(); + if (explicitName != null) user.setPlayerName(explicitName); + when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>()); + when(user.canVoteSite(site)).thenReturn(true); + when(user.getCoolDownCheck()).thenReturn(allSites); + when(user.canVoteAll()).thenReturn(true); + when(user.getJavaUUID()).thenReturn(UUID_VALUE); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE, false)).thenReturn(user); + com.bencodez.advancedcore.api.user.UserManager storage = plugin.getUserManager(); + doAnswer(call -> { + BiConsumer> each = call.getArgument(0); + Consumer done = call.getArgument(1); + ArrayList columns = new ArrayList<>(); + if (cachedName != null) columns.add(new Column("PlayerName", new DataValueString(cachedName))); + each.accept(UUID_VALUE, columns); + done.accept(1); + return null; + }).when(storage).forEachUserKeys(any(), any()); + + CoolDownCheck check = new CoolDownCheck(plugin); + AtomicReference deferred = new AtomicReference<>(); + AtomicReference seen = new AtomicReference<>(); + doAnswer(call -> { + AdvancedCoreUser recipient = call.getArgument(0); + deferred.set(() -> seen.set(recipient.getPlayerName())); + return null; + }).when(rewards).giveReward(any(AdvancedCoreUser.class), any(), anyString(), any(RewardOptions.class)); + doAnswer(call -> { + Event event = call.getArgument(0); + if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event); + if (event instanceof PlayerVoteSiteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteSiteCoolDownEndEvent) event); + return null; + }).when(events).callEvent(any(Event.class)); + try { + if (allSites) check.checkAllVoteSites(); else check.checkAllVoteSite(site); + assertNull(userData.getTempCache()); + assertNotNull(deferred.get(), "cooldown listener should dispatch reward"); + deferred.get().run(); + assertEquals(expected, seen.get()); + assertEquals(UUID_VALUE, user.getJavaUUID()); + verify(user).setCoolDownCheckSite(any()); + if (allSites) verify(user).setCoolDownCheck(false); + } finally { + check.shutdown(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownIdentitySnapshotTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownIdentitySnapshotTest.java new file mode 100644 index 0000000000..4a327a58cc --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownIdentitySnapshotTest.java @@ -0,0 +1,83 @@ +package com.bencodez.votingplugin.tests.cooldown; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class CoolDownIdentitySnapshotTest { + private VotingPluginUser user(String cachedName, String explicitName) throws Exception { + VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + Field dataField = AdvancedCoreUser.class.getDeclaredField("data"); + dataField.setAccessible(true); + UserData data = new UserData(user); + dataField.set(user, data); + user.userDataFetechMode(UserDataFetchMode.TEMP_ONLY); + HashMap cache = new HashMap<>(); + if (cachedName != null) cache.put("PlayerName", new DataValueString(cachedName)); + data.setTempCache(cache); + if (explicitName != null) user.setPlayerName(explicitName); + return user; + } + + private void snapshot(VotingPluginUser user) throws Exception { + Method capture = CoolDownCheck.class.getDeclaredMethod("capturePlayerName", VotingPluginUser.class); + capture.setAccessible(true); + capture.invoke(null, user); + } + + @Test void delayedRewardRetainsNameAfterTemporaryCacheIsCleared() throws Exception { + VotingPluginUser user = user("OfflinePlayer", null); + snapshot(user); + var admitted = new CountDownLatch(1); + var resume = new CountDownLatch(1); + var worker = Executors.newSingleThreadExecutor(); + try { + var result = worker.submit(() -> { + admitted.countDown(); + if (!resume.await(5, TimeUnit.SECONDS)) throw new AssertionError("worker not resumed"); + return user.getPlayerName(); + }); + assertTrue(admitted.await(5, TimeUnit.SECONDS)); + user.clearTempCache(); + resume.countDown(); + assertEquals("OfflinePlayer", result.get(5, TimeUnit.SECONDS)); + } finally { + resume.countDown(); + worker.shutdownNow(); + } + } + + @Test void missingNameRemainsMissingAndExplicitNameIsPreserved() throws Exception { + VotingPluginUser missing = user(null, null); + snapshot(missing); + missing.clearTempCache(); + assertEquals("", missing.getPlayerName()); + VotingPluginUser online = user("OldName", "OnlineName"); + snapshot(online); + online.clearTempCache(); + assertEquals("OnlineName", online.getPlayerName()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownMultipleSitesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownMultipleSitesTest.java new file mode 100644 index 0000000000..2153c2d0de --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownMultipleSitesTest.java @@ -0,0 +1,54 @@ +package com.bencodez.votingplugin.tests.cooldown; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.*; +import org.bukkit.Server; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import com.bencodez.advancedcore.api.user.*; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.*; +class CoolDownMultipleSitesTest { + @org.junit.jupiter.api.Test void dailyBatchPersistsEveryCompletedSite() throws Exception { + var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS); + var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class);var second=mock(VoteSite.class); + var server=mock(Server.class);var events=mock(PluginManager.class); + when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events); + when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true); + when(second.getKey()).thenReturn("second");when(second.isVoteDelayDaily()).thenReturn(true); + when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site,second))); + when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site); + var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin); + Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin); + var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin); + doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites"); + doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString()); + doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode(); + doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache(); + when(user.getCoolDownCheck()).thenReturn(false);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true);when(user.canVoteSite(second)).thenReturn(true); + when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user); + var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer> each=call.getArgument(0);Consumer done=call.getArgument(1); + each.accept(id,new ArrayList<>(List.of(new Column("PlayerName",new DataValueString("OfflinePlayer")),new Column("CoolDownCheck_Sites",new DataValueString("")))));done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any()); + var writes=new ArrayList>(); + doAnswer(call->{writes.add(new HashMap<>(call.getArgument(0)));return null;}).when(user).setCoolDownCheckSite(any()); + var check=new CoolDownCheck(plugin); + check.checkAllVoteSites(); + assertEquals(2,writes.size()); + assertEquals(Map.of("site",true),writes.get(0)); + assertEquals(Map.of("site",true,"second",true),writes.get(1)); + check.shutdown(); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownPendingCacheTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownPendingCacheTest.java new file mode 100644 index 0000000000..9d4d58cf6a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownPendingCacheTest.java @@ -0,0 +1,72 @@ +package com.bencodez.votingplugin.tests.cooldown; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.*; +import org.bukkit.Server; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import com.bencodez.advancedcore.api.user.*; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.*; +class CoolDownPendingCacheTest { + @org.junit.jupiter.api.Test void queuedCompletedFlagWinsOverStalePersistedFlag() throws Exception { + var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS); + var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class); + var server=mock(Server.class);var events=mock(PluginManager.class); + when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events); + when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true); + when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site))); + when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site); + var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin); + Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin); + var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin); + doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites"); + doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString()); + doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode(); + doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache(); + when(user.getCoolDownCheck()).thenReturn(true);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true); + when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user); + var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer> each=call.getArgument(0);Consumer done=call.getArgument(1); + each.accept(id,new ArrayList<>(List.of(new Column("PlayerName",new DataValueString("OfflinePlayer")),new Column("CoolDownCheck_Sites",new DataValueString("site//true")))));done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any()); + var cacheManager=plugin.getUserManager().getDataManager(); + var cache=new com.bencodez.advancedcore.api.user.usercache.UserDataCache(cacheManager,id); + cache.updateCache(new HashMap<>()); + when(cacheManager.getPlugin()).thenReturn(plugin); + when(cacheManager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString("CoolDownCheck_Sites", "site//true"),true); + var fresh=mock(VotingPluginUser.class);p.set(fresh,plugin);ap.set(fresh,plugin); + when(fresh.getPlugin()).thenReturn(plugin);when(fresh.isOnline()).thenReturn(false);when(fresh.isCached()).thenReturn(true); + when(fresh.getCache()).thenReturn(cache);when(fresh.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites"); + doCallRealMethod().when(fresh).getCoolDownCheckSiteList();doCallRealMethod().when(fresh).userDataFetechMode(any()); + doCallRealMethod().when(fresh).getUserDataFetchMode();fresh.userDataFetechMode(UserDataFetchMode.DEFAULT); + var freshData=new UserData(fresh);when(fresh.getData()).thenReturn(freshData);when(fresh.getUserData()).thenReturn(freshData); + var sql=mock(com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess.class); + when(sql.getString(any(),eq("CoolDownCheck_Sites"))).thenReturn("site//false"); + Field sqlField=UserData.class.getDeclaredField("sqlData");sqlField.setAccessible(true);sqlField.set(freshData,sql); + when(fresh.canVoteSite(site)).thenReturn(true); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user,fresh); + var check=new CoolDownCheck(plugin);Field f=CoolDownCheck.class.getDeclaredField("timer");f.setAccessible(true);((ScheduledExecutorService) f.get(check)).shutdownNow();var timer=mock(ScheduledExecutorService.class);var scheduled=new AtomicReference(); + when(timer.schedule(any(Runnable.class),anyLong(),any(TimeUnit.class))).thenAnswer(call->{scheduled.set(call.getArgument(0));return mock(ScheduledFuture.class);}); + f.set(check,timer); + check.checkAllVoteSites();assertNull(data.getTempCache());assertNotNull(scheduled.get()); + verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class)); + scheduled.get().run(); + verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class)); + verify(sql,never()).getString(any(),eq("CoolDownCheck_Sites")); + assertEquals(UserDataFetchMode.DEFAULT, fresh.getUserDataFetchMode()); + check.shutdown(); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardDiagnosticsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardDiagnosticsTest.java new file mode 100644 index 0000000000..8cc5eb7db3 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardDiagnosticsTest.java @@ -0,0 +1,94 @@ +package com.bencodez.votingplugin.tests.cooldown; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.SpecialRewardsConfig; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class CoolDownRewardDiagnosticsTest { + @Test void synchronousFailureIsCorrelatedRedactedAndPropagated() { + Fixture f = new Fixture(true); + IllegalStateException failure = new IllegalStateException("private command and placeholder"); + doThrow(failure).when(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class)); + try { + assertSame(failure, assertThrows(IllegalStateException.class, + () -> f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user)))); + ArgumentCaptor attempted = ArgumentCaptor.forClass(String.class); + ArgumentCaptor warning = ArgumentCaptor.forClass(String.class); + verify(f.logger).info(attempted.capture()); + verify(f.logger).warning(warning.capture()); + var match = java.util.regex.Pattern.compile("occurrence=([0-9a-f-]+)").matcher(attempted.getValue()); + assertTrue(match.find()); + assertTrue(warning.getValue().contains("occurrence=" + match.group(1))); + assertTrue(warning.getValue().contains("player=" + f.uuid)); + assertTrue(warning.getValue().contains("site=all")); + assertTrue(warning.getValue().contains("outcome=unknown/partial")); + assertFalse(warning.getValue().contains("private command")); + } finally { f.check.shutdown(); } + } + + @Test void absentRewardKeepsLegacyDispatchWithoutDiagnosticNoise() { + Fixture f = new Fixture(false); + try { + f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user)); + verify(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class)); + verifyNoInteractions(f.logger); + } finally { f.check.shutdown(); } + } + + @Test void loggerFailureDoesNotSuppressRewardDispatch() { + Fixture f = new Fixture(true); + doThrow(new IllegalStateException("logger unavailable")).when(f.logger).info(anyString()); + try { + f.check.onCoolDownEnd(new PlayerVoteCoolDownEndEvent(f.user)); + verify(f.handler).giveReward(eq(f.user), eq(f.data), eq("VoteCoolDownEndedReward"), any(RewardOptions.class)); + } finally { f.check.shutdown(); } + } + + @Test void nestedSiteSectionProducesAdmissionWithoutChangingDispatch() { + Fixture f = new Fixture(false); + var site = mock(com.bencodez.votingplugin.votesites.VoteSite.class); + var section = f.data.createSection("site"); + section.set("CoolDownEndRewards.Command", "say hello"); + when(site.getSiteData()).thenReturn(section); + when(site.getKey()).thenReturn("example"); + when(site.getDisplayName()).thenReturn("Example"); + when(site.getVoteURL(false)).thenReturn("https://example.test"); + try { + f.check.onCoolDownEnd(new com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent(f.user, site)); + verify(f.logger).info(contains("status=attempted")); + verify(f.handler).giveReward(eq(f.user), eq(section), eq("CoolDownEndRewards"), any(RewardOptions.class)); + } finally { f.check.shutdown(); } + } + + private static final class Fixture { + final UUID uuid = UUID.randomUUID(); + final VotingPluginMain plugin = mock(VotingPluginMain.class); + final VotingPluginUser user = mock(VotingPluginUser.class); + final SpecialRewardsConfig special = mock(SpecialRewardsConfig.class); + final RewardHandler handler = mock(RewardHandler.class); + final Logger logger = mock(Logger.class); + final YamlConfiguration data = new YamlConfiguration(); + final CoolDownCheck check; + Fixture(boolean configured) { + when(plugin.getSpecialRewardsConfig()).thenReturn(special); + when(special.getData()).thenReturn(data); + when(plugin.getRewardHandler()).thenReturn(handler); + when(plugin.getLogger()).thenReturn(logger); + when(user.getUUID()).thenReturn(uuid.toString()); + when(handler.hasRewards(data, "VoteCoolDownEndedReward")).thenReturn(configured); + check = new CoolDownCheck(plugin); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java new file mode 100644 index 0000000000..aad5cdd56d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java @@ -0,0 +1,229 @@ +package com.bencodez.votingplugin.tests.cooldown; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Path; + +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.config.SpecialRewardsConfig; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.events.PlayerVoteCoolDownEndEvent; +import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.bencodez.votingplugin.votesites.VoteSiteManager; + +class CoolDownRewardIntegrationTest { + @TempDir Path tempDir; + private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123"); + + @Test void singleSiteCommandListUsesCapturedOfflineName() throws Exception { exercise(false, "OfflinePlayer", false, false, false, false, false); } + @Test void allSiteCommandUsesCapturedOfflineName() throws Exception { exercise(true, "OfflinePlayer", false, false, false, false, false); } + @Test void missingIdentityRejectsPlayerTargetAndLogsFailure() throws Exception { exercise(false, null, false, true, false, false, false); } + @Test void missingIdentityAllowsLiteralCommand() throws Exception { exercise(false, null, true, false, false, false, false); } + + @Test void onlineIdentityUsesPlayerOwnerScheduler() throws Exception { exercise(false, "OldName", false, false, true, false, false); } + + @Test void missingIdentityDoesNotReplayCompletedEffect() throws Exception { exercise(false, null, false, true, false, true, false); } + + @Test void missingOfflineNameCannotTargetUnrelatedPlayer() throws Exception { exercise(false, null, true, true, false, false, true); } + + private void exercise(boolean allSites, String cachedName, boolean literal, boolean reject, boolean online, boolean partial, boolean mixed) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + Config config = mock(Config.class); + VoteSiteManager sites = mock(VoteSiteManager.class); + VoteSite site = mock(VoteSite.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + RewardHandler rewards = new RewardHandler(plugin); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommand(rewards, plugin); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(rewards, plugin); + java.util.concurrent.atomic.AtomicInteger completed = new java.util.concurrent.atomic.AtomicInteger(); + if (partial) rewards.getInjectedRewards().add(0, new com.bencodez.advancedcore.api.rewards.injected.RewardInject("Marker") { + @Override public Object onRewardRequest(com.bencodez.advancedcore.api.rewards.Reward reward, + AdvancedCoreUser recipient, org.bukkit.configuration.ConfigurationSection data, + HashMap placeholders) { completed.incrementAndGet(); return null; } + }); + SpecialRewardsConfig special = mock(SpecialRewardsConfig.class); + Server server = mock(Server.class); + PluginManager events = mock(PluginManager.class); + YamlConfiguration data = new YamlConfiguration(); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getOptions().getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + var oldInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance(); + com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(plugin); + var misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + Field miscPlugin = misc.getClass().getDeclaredField("plugin"); miscPlugin.setAccessible(true); + Object oldMiscPlugin = miscPlugin.get(misc); miscPlugin.set(misc, plugin); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + java.util.ArrayDeque queued = new java.util.ArrayDeque<>(); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Player.class)); + doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { queued.add(call.getArgument(1)); return null; }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile()).thenReturn(config); + when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getSpecialRewardsConfig()).thenReturn(special); + when(special.getData()).thenReturn(data); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(events); + when(site.getKey()).thenReturn("site"); + when(site.getDisplayName()).thenReturn("Site"); + when(site.getVoteURL(false)).thenReturn("https://example.test"); + when(site.getSiteData()).thenReturn(data); + when(site.isVoteDelayDaily()).thenReturn(true); + when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site))); + + VotingPluginUser user = mock(VotingPluginUser.class); + Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + Field dataField = AdvancedCoreUser.class.getDeclaredField("data"); + dataField.setAccessible(true); + UserData userData = new UserData(user); + dataField.set(user, userData); + when(user.getUserData()).thenReturn(userData); + when(user.getUUID()).thenReturn(UUID_VALUE.toString()); + when(user.endAsyncActionCollection(any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + when(user.getPlugin()).thenReturn(plugin); + doCallRealMethod().when(user).getPlayerName(); + doCallRealMethod().when(user).setPlayerName(anyString()); + doCallRealMethod().when(user).userDataFetechMode(any(UserDataFetchMode.class)); + doCallRealMethod().when(user).updateTempCacheWithColumns(any()); + doCallRealMethod().when(user).clearTempCache(); + org.bukkit.entity.Player live = mock(org.bukkit.entity.Player.class); + if (mixed) { + when(plugin.getOptions().isOnlineMode()).thenReturn(false); + doCallRealMethod().when(user).getPlayer(); + doCallRealMethod().when(user).validatePlayerCommandAvailabilityAsync(); + } + if (online) { + when(user.isOnline()).thenReturn(true); + when(user.getPlayer()).thenReturn(live); + user.setPlayerName("OnlinePlayer"); + } + when(user.getCoolDownCheckSiteList()).thenReturn(new HashMap<>()); + when(user.canVoteSite(site)).thenReturn(true); + when(user.getCoolDownCheck()).thenReturn(allSites); + when(user.canVoteAll()).thenReturn(true); + when(user.getJavaUUID()).thenReturn(UUID_VALUE); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(UUID_VALUE, false)).thenReturn(user); + com.bencodez.advancedcore.api.user.UserManager storage = plugin.getUserManager(); + doAnswer(call -> { + BiConsumer> each = call.getArgument(0); + Consumer done = call.getArgument(1); + ArrayList columns = new ArrayList<>(); + if (cachedName != null) columns.add(new Column("PlayerName", new DataValueString(cachedName))); + each.accept(UUID_VALUE, columns); + done.accept(1); + return null; + }).when(storage).forEachUserKeys(any(), any()); + + data.set("VoteCoolDownEndedReward.ForceOffline", true); + data.set("VoteCoolDownEndedReward.Command", literal ? "say ready" : "say %player%"); + data.set("CoolDownEndRewards.ForceOffline", true); + if (partial) data.set("CoolDownEndRewards.Marker", true); + if (mixed) { + data.set("CoolDownEndRewards.Commands.Console", new ArrayList<>(List.of("say ready"))); + data.set("CoolDownEndRewards.Commands.Player", new ArrayList<>(List.of("unrelated-action"))); + } else data.set("CoolDownEndRewards.Commands", new ArrayList<>(List.of(literal ? "say ready" : "say %player%"))); + CoolDownCheck check = new CoolDownCheck(plugin); + doAnswer(call -> { + Event event = call.getArgument(0); + if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event); + if (event instanceof PlayerVoteSiteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteSiteCoolDownEndEvent) event); + return null; + }).when(events).callEvent(any(Event.class)); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + var console = mock(org.bukkit.command.ConsoleCommandSender.class); + var offline = mock(org.bukkit.OfflinePlayer.class); + var unrelated = mock(org.bukkit.entity.Player.class); + bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(server); + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(events); + bukkit.when(org.bukkit.Bukkit::getConsoleSender).thenReturn(console); + bukkit.when(() -> org.bukkit.Bukkit.getOfflinePlayer(anyString())).thenReturn(offline); + if (online) { when(offline.isOnline()).thenReturn(true); when(offline.getPlayer()).thenReturn(live); } + if (mixed) bukkit.when(() -> org.bukkit.Bukkit.getPlayer("")).thenReturn(unrelated); + if (allSites) check.checkAllVoteSites(); else check.checkAllVoteSite(site); + assertNull(userData.getTempCache()); + assertFalse(queued.isEmpty(), "real reward must be deferred"); + verify(server, never()).dispatchCommand(any(), anyString()); + while (!queued.isEmpty()) queued.remove().run(); + if (reject) { + verify(server, never()).dispatchCommand(any(), anyString()); + verify(logger).log(eq(java.util.logging.Level.WARNING), contains("Failed to give reward"), any(Throwable.class)); + var attempted = org.mockito.ArgumentCaptor.forClass(String.class); + var failure = org.mockito.ArgumentCaptor.forClass(String.class); + verify(logger, atLeastOnce()).info(attempted.capture()); + verify(logger).warning(failure.capture()); + String admission = attempted.getAllValues().stream() + .filter(value -> value.startsWith("Cooldown reward status=attempted")) + .findFirst().orElseThrow(); + var matcher = java.util.regex.Pattern.compile("occurrence=([0-9a-f-]+)").matcher(admission); + assertTrue(matcher.find()); + assertTrue(failure.getValue().contains("occurrence=" + matcher.group(1))); + assertTrue(failure.getValue().contains("player=" + UUID_VALUE)); + assertTrue(failure.getValue().contains("site=site:site")); + assertTrue(failure.getValue().contains("outcome=unknown/partial")); + assertTrue(failure.getValue().contains("completedInjections=")); + if (partial) { + var count = java.util.regex.Pattern.compile("completedInjections=([0-9]+)").matcher(failure.getValue()); + assertTrue(count.find()); + assertTrue(Integer.parseInt(count.group(1)) >= 1); + } + assertFalse(failure.getValue().contains("%player%")); + assertEquals(partial ? 1 : 0, completed.get()); + } else { + verify(server, times(allSites ? 2 : 1)).dispatchCommand(console, literal ? "say ready" : online ? "say OnlinePlayer" : "say OfflinePlayer"); + } + bukkit.verify(() -> org.bukkit.Bukkit.getOfflinePlayer(""), never()); + if (mixed) { + bukkit.verify(() -> org.bukkit.Bukkit.getPlayer(""), never()); + verify(unrelated, never()).chat(anyString()); + } + verify(scheduler, atLeastOnce()).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + verify(scheduler, atLeastOnce()).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + if (online) verify(scheduler, atLeastOnce()).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(live)); + assertEquals(UUID_VALUE, user.getJavaUUID()); + verify(user).setCoolDownCheckSite(any()); + if (allSites) verify(user).setCoolDownCheck(false); + } finally { + check.shutdown(); + rewards.getDelayedTimer().shutdownNow(); + miscPlugin.set(misc, oldMiscPlugin); + com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(oldInstance); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java new file mode 100644 index 0000000000..f9acc1db0d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java @@ -0,0 +1,119 @@ +package com.bencodez.votingplugin.tests.cooldown; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.NextSite; +import com.bencodez.votingplugin.votesites.VoteSite; + +class CoolDownTimerGenerationTest { + @Test void replacedTimerDoesNotLoadOrDeliver() throws Exception { + Fixture f = new Fixture(); + try { + f.check.schedulePerSite(f.user, true); + f.check.schedulePerSite(f.user, true); + f.tasks.get(0).run(); + verify(f.plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(f.uuid, false); + f.tasks.get(1).run(); + verify(f.plugin.getVotingPluginUserManager()).getVotingPluginUser(f.uuid, false); + Field generations = CoolDownCheck.class.getDeclaredField("perSiteGenerations"); + generations.setAccessible(true); + assertFalse(((java.util.Map) generations.get(f.check)).containsKey(f.uuid)); + } finally { f.check.shutdown(); } + } + + @Test void replacementWhileLoadingStopsOldTimerBeforeSiteCheck() throws Exception { + Fixture f = new Fixture(); + try { + f.check.schedulePerSite(f.user, true); + var manager = f.plugin.getVotingPluginUserManager(); + doAnswer(call -> { f.check.schedulePerSite(f.user, true); return f.fresh; }) + .when(manager).getVotingPluginUser(f.uuid, false); + f.tasks.get(0).run(); + verify(f.fresh, never()).getCoolDownCheckSiteList(); + assertEquals(2, f.tasks.size()); + } finally { f.check.shutdown(); } + } + + @Test void shutdownRetiresAcceptedTimer() throws Exception { + Fixture f = new Fixture(); + f.check.schedulePerSite(f.user, true); + f.check.shutdown(); + f.tasks.get(0).run(); + verify(f.plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(f.uuid, false); + f.check.schedulePerSite(f.user, true); + assertEquals(1, f.tasks.size()); + } + + @Test void timerFailureLogsCorrelatedUncertainOutcomeAndPropagates() throws Exception { + Fixture f = new Fixture(); + try { + f.check.schedulePerSite(f.user, true); + var data = f.fresh.getData(); + when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class))).thenThrow(new IllegalStateException("private detail")); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> f.tasks.get(0).run()); + assertEquals("private detail", thrown.getMessage()); + verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("occurrence=") + && message.contains("player=" + f.uuid) && message.contains("site=site") + && message.contains("time=") && message.contains("outcome is uncertain") + && !message.contains("private detail"))); + } finally { f.check.shutdown(); } + } + + @Test void retiredCacheFailureIsVisibleAndDoesNotChangeSiteFlags() throws Exception { + Fixture f = new Fixture(); + try { + f.check.schedulePerSite(f.user, true); + when(f.fresh.getCoolDownCheckSiteList()).thenThrow(new IllegalStateException("cache retired")); + assertThrows(IllegalStateException.class, () -> f.tasks.get(0).run()); + verify(f.fresh, never()).setCoolDownCheckSite(any()); + verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("player=" + f.uuid) + && message.contains("site=site") && message.contains("outcome is uncertain") + && !message.contains("cache retired"))); + } finally { f.check.shutdown(); } + } + + private static final class Fixture { + final UUID uuid = UUID.randomUUID(); + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final VotingPluginUser user = mock(VotingPluginUser.class); + final VotingPluginUser fresh = mock(VotingPluginUser.class); + final VoteSite site = mock(VoteSite.class); + final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + final ArrayList tasks = new ArrayList<>(); + final CoolDownCheck check; + Fixture() throws Exception { + Config config = mock(Config.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getConfigFile()).thenReturn(config); + when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(site.getKey()).thenReturn("site"); + when(user.getJavaUUID()).thenReturn(uuid); + when(user.getPlayerName()).thenReturn("PriorName"); + when(user.getNextSiteAvailable()).thenReturn(new NextSite(site, 60)); + when(fresh.getData()).thenReturn(mock(UserData.class)); + when(fresh.getCoolDownCheckSiteList()).thenReturn(new HashMap<>(java.util.Map.of("site", true))); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false)).thenReturn(fresh); + doAnswer(call -> { tasks.add(call.getArgument(0)); return mock(ScheduledFuture.class); }) + .when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + check = new CoolDownCheck(plugin); + Field field = CoolDownCheck.class.getDeclaredField("timer"); + field.setAccessible(true); + ((ScheduledExecutorService) field.get(check)).shutdownNow(); + field.set(check, timer); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerRefreshTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerRefreshTest.java new file mode 100644 index 0000000000..5dc94b998a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerRefreshTest.java @@ -0,0 +1,70 @@ +package com.bencodez.votingplugin.tests.cooldown; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.*; +import org.bukkit.Server; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import com.bencodez.advancedcore.api.user.*; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.cooldown.CoolDownCheck; +import com.bencodez.votingplugin.events.PlayerVoteSiteCoolDownEndEvent; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.*; +class CoolDownTimerRefreshTest { + @org.junit.jupiter.api.Test void alreadyCompletedSiteIsNotDeliveredAgain() throws Exception { exercise(true, true, "OfflinePlayer", "Renamed", "Renamed"); } + @org.junit.jupiter.api.Test void newerVoteIsConsultedBeforeDelivering() throws Exception { exercise(false, false, "OfflinePlayer", "", "OfflinePlayer"); } + @org.junit.jupiter.api.Test void missingNameDoesNotInventIdentity() throws Exception { exercise(true, true, null, "", null); } + private void exercise(boolean alreadyCompleted, boolean eligible, String scheduledName, String storedName, String expectedName) throws Exception { + var id=UUID.randomUUID(); var plugin=mock(VotingPluginMain.class,RETURNS_DEEP_STUBS); + var config=mock(Config.class);var sites=mock(VoteSiteManager.class);var site=mock(VoteSite.class); + var server=mock(Server.class);var events=mock(PluginManager.class); + when(plugin.isEnabled()).thenReturn(true);when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getConfigFile()).thenReturn(config);when(config.isPerSiteCoolDownEvents()).thenReturn(true); + when(plugin.getVoteSiteManager()).thenReturn(sites);when(plugin.getServer()).thenReturn(server);when(server.getPluginManager()).thenReturn(events); + when(site.getKey()).thenReturn("site");when(site.isVoteDelayDaily()).thenReturn(true); + when(sites.getVoteSites()).thenReturn(new ArrayList<>(List.of(site))); + when(sites.hasVoteSite("site")).thenReturn(true);when(sites.getVoteSite("site",true)).thenReturn(site); + var user=mock(VotingPluginUser.class); Field p=VotingPluginUser.class.getDeclaredField("plugin");p.setAccessible(true);p.set(user,plugin); + Field ap=AdvancedCoreUser.class.getDeclaredField("plugin");ap.setAccessible(true);ap.set(user,plugin); + var data=new UserData(user);when(user.getData()).thenReturn(data);when(user.getUserData()).thenReturn(data);when(user.getPlugin()).thenReturn(plugin); + doCallRealMethod().when(user).getCoolDownCheckSiteList();when(user.getCoolDownCheckSitePath()).thenReturn("CoolDownCheck_Sites"); + doCallRealMethod().when(user).getPlayerName();doCallRealMethod().when(user).setPlayerName(anyString()); + doCallRealMethod().when(user).userDataFetechMode(any());doCallRealMethod().when(user).getUserDataFetchMode(); + doCallRealMethod().when(user).updateTempCacheWithColumns(any());doCallRealMethod().when(user).clearTempCache(); + when(user.getCoolDownCheck()).thenReturn(true);when(user.canVoteAll()).thenReturn(false);when(user.canVoteSite(site)).thenReturn(true); + when(user.getSitesVotedOn()).thenReturn(1);when(user.getJavaUUID()).thenReturn(id);when(user.getNextSiteAvailable()).thenReturn(new NextSite(site,60)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user); + var storage=plugin.getUserManager(); doAnswer(call->{BiConsumer> each=call.getArgument(0);Consumer done=call.getArgument(1); + ArrayList columns=new ArrayList<>(); if(scheduledName != null) columns.add(new Column("PlayerName",new DataValueString(scheduledName))); columns.add(new Column("CoolDownCheck_Sites",new DataValueString("site//true"))); each.accept(id,columns);done.accept(1);return null;}).when(storage).forEachUserKeys(any(),any()); + var fresh=mock(VotingPluginUser.class); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(id,false)).thenReturn(user, fresh); + var freshData=mock(UserData.class); when(fresh.getData()).thenReturn(freshData); + when(freshData.getString(eq("PlayerName"),nullable(UserDataFetchMode.class))).thenReturn(storedName); + var freshFlags=new HashMap(); + if (alreadyCompleted) freshFlags.put("site", true); + when(fresh.getCoolDownCheckSiteList()).thenReturn(freshFlags); + when(fresh.canVoteSite(site)).thenReturn(eligible); + var check=new CoolDownCheck(plugin);Field f=CoolDownCheck.class.getDeclaredField("timer");f.setAccessible(true);((ScheduledExecutorService) f.get(check)).shutdownNow();var timer=mock(ScheduledExecutorService.class);var scheduled=new AtomicReference(); + when(timer.schedule(any(Runnable.class),anyLong(),any(TimeUnit.class))).thenAnswer(call->{scheduled.set(call.getArgument(0));return mock(ScheduledFuture.class);}); + f.set(check,timer); + check.checkAllVoteSites();assertNull(data.getTempCache());assertNotNull(scheduled.get()); + verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class)); + try (var bukkit=mockStatic(org.bukkit.Bukkit.class)) { scheduled.get().run(); bukkit.verifyNoInteractions(); } + verify(events,never()).callEvent(isA(PlayerVoteSiteCoolDownEndEvent.class)); + verify(plugin.getVotingPluginUserManager(),times(2)).getVotingPluginUser(id,false); + verify(plugin.getVotingPluginUserManager(),never()).getVotingPluginUser(id); + if (expectedName == null) verify(fresh,never()).setPlayerName(anyString()); + else verify(fresh).setPlayerName(expectedName); + verify(fresh).userDataFetechMode(UserDataFetchMode.NO_CACHE); + verify(fresh,never()).setCoolDownCheckSite(any()); + check.shutdown(); + } +} diff --git a/docs/cooldown-reward-reconciliation.md b/docs/cooldown-reward-reconciliation.md new file mode 100644 index 0000000000..086ea476ec --- /dev/null +++ b/docs/cooldown-reward-reconciliation.md @@ -0,0 +1,13 @@ +# Reconciling uncertain cooldown rewards + +Cooldown flags record that the cooldown event was raised. They do not prove that every reward effect completed. A reward may contain several effects, and a failure can occur after some effects have already happened. + +For configured cooldown rewards, the server log records `Cooldown reward status=attempted` with an occurrence ID, player UUID, site (or `all`), and time. This means the reward listener attempted dispatch; it is not proof of delivery. An asynchronous terminal failure produces a warning with the same context, `outcome=unknown/partial`, and an injection count when available. That count is a diagnostic, not a list of commands or proof that the other effects did not happen. More than one warning can refer to the same occurrence when a configured reward list has multiple failures. A timer failure has a separate correlation ID and may happen before or after an event. + +Before considering any manual correction: + +1. Preserve the relevant server logs, configuration and reward definitions, and the occurrence ID, UUID, site, and time. Check logs from the server that processed the event. +2. For each configured effect, inspect independent records from the affected system. A failed command can have an uncertain outcome; the log alone cannot establish whether it applied. An unavailable or ambiguous record means the effect remains unknown. +3. After identifying an independently confirmed missing effect, an authorized operator can issue only that effect and record the correction. Do not reset cooldown flags or replay the complete reward merely because a warning appeared: earlier effects may already have completed. + +These logs are best-effort diagnostics, not a durable reward journal. An event cancelled by another plugin, disabled reward processing, a deferred reward, an internally caught legacy requirement error, logger failure, shutdown, or a crash can leave no terminal warning. A crash between the flag change and the listener's attempted-dispatch log can leave no occurrence entry at all. Missing logs do not establish success or failure, and a logged attempt does not establish the outcome. Keep external transaction evidence for reconciliation; do not assume exactly-once delivery. From ee33a291a61a7ab8b66509abb3f7e4d40bdd77b7 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 29 Oct 2026 18:08:53 -0600 Subject: [PATCH 2/3] test(cooldown): restore globals after partial fixture setup --- .../CoolDownRewardIntegrationTest.java | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java index aad5cdd56d..3f21e41ccd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownRewardIntegrationTest.java @@ -40,6 +40,64 @@ class CoolDownRewardIntegrationTest { @TempDir Path tempDir; + private boolean globalsChanged; + private com.bencodez.advancedcore.AdvancedCorePlugin oldInstance; + private com.bencodez.advancedcore.api.misc.MiscUtils misc; + private Field miscPlugin; + private Object oldMiscPlugin; + private RewardHandler activeRewards; + private CoolDownCheck activeCheck; + + private void installGlobals(VotingPluginMain plugin) throws Exception { + oldInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance(); + misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + miscPlugin = misc.getClass().getDeclaredField("plugin"); + miscPlugin.setAccessible(true); + oldMiscPlugin = miscPlugin.get(misc); + globalsChanged = true; + com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(plugin); + miscPlugin.set(misc, plugin); + } + + @org.junit.jupiter.api.AfterEach void cleanup() throws Exception { + try { + if (activeCheck != null) activeCheck.shutdown(); + } finally { + try { + if (activeRewards != null) activeRewards.getDelayedTimer().shutdownNow(); + } finally { + if (globalsChanged) { + try { miscPlugin.set(misc, oldMiscPlugin); } + finally { + com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(oldInstance); + globalsChanged = false; + } + } + } + } + } + + @Test void cleanupRestoresNullInstanceAfterPartialSetupFailure() throws Exception { + var originalInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance(); + var originalMisc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); + Field field = originalMisc.getClass().getDeclaredField("plugin"); + field.setAccessible(true); + Object originalMiscPlugin = field.get(originalMisc); + try { + com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(null); + assertThrows(IllegalStateException.class, () -> { + installGlobals(mock(VotingPluginMain.class)); + throw new IllegalStateException("fixture setup failed"); + }); + cleanup(); + assertNull(com.bencodez.advancedcore.AdvancedCorePlugin.getInstance()); + assertSame(originalMiscPlugin, field.get(originalMisc)); + } finally { + cleanup(); + field.set(originalMisc, originalMiscPlugin); + com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(originalInstance); + } + } private static final UUID UUID_VALUE = UUID.fromString("00000000-0000-0000-0000-000000000123"); @Test void singleSiteCommandListUsesCapturedOfflineName() throws Exception { exercise(false, "OfflinePlayer", false, false, false, false, false); } @@ -60,6 +118,7 @@ private void exercise(boolean allSites, String cachedName, boolean literal, bool VoteSite site = mock(VoteSite.class); when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); RewardHandler rewards = new RewardHandler(plugin); + activeRewards = rewards; com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommand(rewards, plugin); com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(rewards, plugin); java.util.concurrent.atomic.AtomicInteger completed = new java.util.concurrent.atomic.AtomicInteger(); @@ -75,11 +134,7 @@ private void exercise(boolean allSites, String cachedName, boolean literal, bool when(plugin.isEnabled()).thenReturn(true); when(plugin.getOptions().isProcessRewards()).thenReturn(true); when(plugin.getOptions().getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); - var oldInstance = com.bencodez.advancedcore.AdvancedCorePlugin.getInstance(); - com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(plugin); - var misc = com.bencodez.advancedcore.api.misc.MiscUtils.getInstance(); - Field miscPlugin = misc.getClass().getDeclaredField("plugin"); miscPlugin.setAccessible(true); - Object oldMiscPlugin = miscPlugin.get(misc); miscPlugin.set(misc, plugin); + installGlobals(plugin); var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); java.util.ArrayDeque queued = new java.util.ArrayDeque<>(); when(plugin.getBukkitScheduler()).thenReturn(scheduler); @@ -160,6 +215,7 @@ private void exercise(boolean allSites, String cachedName, boolean literal, bool data.set("CoolDownEndRewards.Commands.Player", new ArrayList<>(List.of("unrelated-action"))); } else data.set("CoolDownEndRewards.Commands", new ArrayList<>(List.of(literal ? "say ready" : "say %player%"))); CoolDownCheck check = new CoolDownCheck(plugin); + activeCheck = check; doAnswer(call -> { Event event = call.getArgument(0); if (event instanceof PlayerVoteCoolDownEndEvent) check.onCoolDownEnd((PlayerVoteCoolDownEndEvent) event); @@ -219,11 +275,6 @@ private void exercise(boolean allSites, String cachedName, boolean literal, bool assertEquals(UUID_VALUE, user.getJavaUUID()); verify(user).setCoolDownCheckSite(any()); if (allSites) verify(user).setCoolDownCheck(false); - } finally { - check.shutdown(); - rewards.getDelayedTimer().shutdownNow(); - miscPlugin.set(misc, oldMiscPlugin); - com.bencodez.advancedcore.AdvancedCorePlugin.setInstance(oldInstance); } } } From 7746fc52dc851bf71757a208e05b167cf96898cf Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 29 Oct 2026 18:50:03 -0600 Subject: [PATCH 3/3] Retry cooldown identity refresh before reward dispatch --- .../votingplugin/cooldown/CoolDownCheck.java | 73 +++++++++++-------- .../cooldown/CoolDownTimerGenerationTest.java | 39 +++++++++- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java index 9376cd5da9..5f5b4db7b3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/cooldown/CoolDownCheck.java @@ -436,35 +436,8 @@ public void schedulePerSite(VotingPluginUser user, boolean force) { Object generation = new Object(); perSiteGenerations.put(uuid, generation); try { - return timer.schedule(() -> { - if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) { - return; - } - UUID occurrence = UUID.randomUUID(); - try { - // The batch user's temporary row may have been cleared or replaced by a newer vote. - VotingPluginUser current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); - if (!current.isCached()) { - current.userDataFetechMode(UserDataFetchMode.NO_CACHE); - } - String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode()); - String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName; - if (name != null && !name.isBlank()) { - current.setPlayerName(name); - } - if (perSiteGenerations.get(uuid) != generation) return; - checkPerSite(next.getSite(), current); - if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false); - } catch (RuntimeException | Error failure) { - String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?"); - logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid - + " site=" + siteKey + " time=" + System.currentTimeMillis() + " error=" - + failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing"); - throw failure; - } finally { - perSiteGenerations.remove(uuid, generation); - } - }, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS); + return timer.schedule(() -> runPerSiteTimer(uuid, scheduledName, next, generation, 0), + next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS); } catch (RuntimeException | Error failure) { perSiteGenerations.remove(uuid, generation); throw failure; @@ -472,6 +445,48 @@ public void schedulePerSite(VotingPluginUser user, boolean force) { }); } + + private void runPerSiteTimer(UUID uuid, String scheduledName, NextSite next, Object generation, int retries) { + if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return; + UUID occurrence = UUID.randomUUID(); + boolean[] retried = { false }; + try { + VotingPluginUser current; + try { + // The batch user's temporary row may have been cleared or replaced by a newer vote. + current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + if (!current.isCached()) current.userDataFetechMode(UserDataFetchMode.NO_CACHE); + String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode()); + String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName; + if (name != null && !name.isBlank()) current.setPlayerName(name); + } catch (RuntimeException refreshFailure) { + if (retries < 3) { + perSiteTasks.compute(uuid, (key, existing) -> { + if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return existing; + ScheduledFuture retry = timer.schedule( + () -> runPerSiteTimer(uuid, scheduledName, next, generation, retries + 1), + 30, TimeUnit.SECONDS); + retried[0] = true; + return retry; + }); + if (retried[0]) return; + } + throw refreshFailure; + } + if (perSiteGenerations.get(uuid) != generation) return; + checkPerSite(next.getSite(), current); + if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false); + } catch (RuntimeException | Error failure) { + String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?"); + logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid + + " site=" + siteKey + " time=" + System.currentTimeMillis() + " error=" + + failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing"); + throw failure; + } finally { + if (!retried[0]) perSiteGenerations.remove(uuid, generation); + } + } + /** * Handles a vote event for cooldown scheduling. * diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java index f9acc1db0d..bde2c323b4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/cooldown/CoolDownTimerGenerationTest.java @@ -64,7 +64,10 @@ class CoolDownTimerGenerationTest { f.check.schedulePerSite(f.user, true); var data = f.fresh.getData(); when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class))).thenThrow(new IllegalStateException("private detail")); - IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> f.tasks.get(0).run()); + f.tasks.get(0).run(); + f.tasks.get(1).run(); + f.tasks.get(2).run(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> f.tasks.get(3).run()); assertEquals("private detail", thrown.getMessage()); verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("occurrence=") && message.contains("player=" + f.uuid) && message.contains("site=site") @@ -73,6 +76,39 @@ class CoolDownTimerGenerationTest { } finally { f.check.shutdown(); } } + @Test void failedRefreshRetriesThenChecksWithoutReplaying() throws Exception { + Fixture f = new Fixture(); + try { + var data = f.fresh.getData(); + when(data.getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class))) + .thenThrow(new IllegalStateException("unavailable")).thenReturn("CurrentName"); + f.check.schedulePerSite(f.user, true); + f.tasks.get(0).run(); + verify(f.fresh, never()).getCoolDownCheckSiteList(); + assertEquals(2, f.tasks.size()); + verify(f.timer).schedule(any(Runnable.class), eq(30L), eq(TimeUnit.SECONDS)); + f.tasks.get(1).run(); + verify(f.fresh).setPlayerName("CurrentName"); + verify(f.fresh).getCoolDownCheckSiteList(); + } finally { f.check.shutdown(); } + } + + @Test void replacedOrStoppedRetryCannotLoad() throws Exception { + Fixture f = new Fixture(); + try { + when(f.fresh.getData().getString(eq("PlayerName"), nullable(com.bencodez.advancedcore.api.user.UserDataFetchMode.class))) + .thenThrow(new IllegalStateException("unavailable")); + f.check.schedulePerSite(f.user, true); + f.tasks.get(0).run(); + f.check.schedulePerSite(f.user, true); + f.tasks.get(1).run(); + verify(f.plugin.getVotingPluginUserManager(), times(1)).getVotingPluginUser(f.uuid, false); + f.check.shutdown(); + f.tasks.get(2).run(); + verify(f.plugin.getVotingPluginUserManager(), times(1)).getVotingPluginUser(f.uuid, false); + } finally { f.check.shutdown(); } + } + @Test void retiredCacheFailureIsVisibleAndDoesNotChangeSiteFlags() throws Exception { Fixture f = new Fixture(); try { @@ -80,6 +116,7 @@ class CoolDownTimerGenerationTest { when(f.fresh.getCoolDownCheckSiteList()).thenThrow(new IllegalStateException("cache retired")); assertThrows(IllegalStateException.class, () -> f.tasks.get(0).run()); verify(f.fresh, never()).setCoolDownCheckSite(any()); + assertEquals(1, f.tasks.size()); verify(f.plugin.getLogger()).warning(argThat((String message) -> message.contains("player=" + f.uuid) && message.contains("site=site") && message.contains("outcome is uncertain") && !message.contains("cache retired")));