diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 62ca65dd2..d85fd81a3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -244,7 +244,16 @@ public void giveReward(VotingPluginUser user, boolean online, boolean useBungee) */ public void giveRewards(VotingPluginUser orgUser, boolean forceBungee) { rewardHandler.giveRewards(orgUser, forceBungee); - reset(false); + // Clear this party's recipient list immediately, as before. Delaying it + // would risk wiping voters recorded for the next party. + setVotedUsers(new ArrayList<>()); + // Shared-MySQL recipients capture eligibility on AdvancedCore's ordered + // storage worker. Only the persisted per-user counter wipe must run + // behind those reads; it must not overtake deferred eligibility checks. + com.bencodez.advancedcore.api.user.usercache.UserDataManager manager = + plugin.getUserManager().getDataManager(); + if (manager != null && manager.deferSharedStorageWork(this::resetVotePartyCount)) return; + resetVotePartyCount(); } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyRewardHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyRewardHandler.java index 5e8d8bdb6..33e09c935 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyRewardHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyRewardHandler.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -11,6 +13,7 @@ import com.bencodez.advancedcore.api.misc.MiscUtils; import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.user.VotingPluginUser; @@ -37,10 +40,15 @@ public void giveReward(VotingPluginUser user, boolean useBungee) { } public void giveReward(VotingPluginUser user, boolean online, boolean useBungee) { + givePreparedReward(user, online, useBungee, !plugin.getServerData().isLastVotePartySameDay()); + } + + private void givePreparedReward(VotingPluginUser user, boolean online, boolean useBungee, + boolean firstVotePartyToday) { new RewardBuilder(plugin.getSpecialRewardsConfig().getData(), plugin.getSpecialRewardsConfig().getVotePartyRewardsPath()).setOnline(online) .withPlaceHolder("VotesRequired", "" + plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()) - .withPlaceHolder("FirstVotePartyToday", "" + !plugin.getServerData().isLastVotePartySameDay()) + .withPlaceHolder("FirstVotePartyToday", "" + firstVotePartyToday) .setServer(useBungee).send(user); } @@ -82,13 +90,14 @@ private void dispatchGlobalCommands(String randomPlayer) { } } + /** Dispatch to every eligible user, preserving each online player's scheduler owner. */ private void giveAllPlayers(VotingPluginUser triggeringUser, boolean forceBungee) { plugin.debug("Trying to give all players vote party"); ArrayList alreadyGiven = new ArrayList<>(); for (Player player : Bukkit.getOnlinePlayers()) { VotingPluginUser user = resolveUser(triggeringUser, player.getUniqueId(), player); if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) { - voteParty.giveReward(user, forceBungee); + giveRewardWhenLoaded(user, forceBungee, player); } alreadyGiven.add(player.getUniqueId().toString()); } @@ -99,30 +108,115 @@ private void giveAllPlayers(VotingPluginUser triggeringUser, boolean forceBungee } VotingPluginUser user = resolveUser(triggeringUser, UUID.fromString(uuid), null); if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) { - voteParty.giveReward(user, forceBungee); + giveRewardWhenLoaded(user, forceBungee, null); } } } + /** Dispatch to users recorded for this party before its voter list is reset. */ private void giveVotedPlayers(VotingPluginUser triggeringUser, boolean forceBungee) { plugin.debug("Trying to give all voted players vote party"); plugin.debug(ArrayUtils.makeStringList(voteParty.getVotedUsers())); for (String uuid : voteParty.getVotedUsers()) { VotingPluginUser user = resolveUser(triggeringUser, UUID.fromString(uuid), null); if (!plugin.getSpecialRewardsConfig().isVotePartyGiveOnlinePlayersOnly() || user.isOnline()) { - voteParty.giveReward(user, forceBungee); + // Capture a live player only on a platform-owned thread. The vote path + // can also originate from a storage worker, where Bukkit lookups are unsafe. + UserDataManager manager = sharedDataManager(); + Player owner = manager != null && manager.isPlatformOwnedThread() + ? Bukkit.getPlayer(user.getJavaUUID()) : null; + giveRewardWhenLoaded(user, forceBungee, owner); } } } + /** Resolve a recipient without forcing an uncached SQL read for shared storage. */ private VotingPluginUser resolveUser(VotingPluginUser triggeringUser, UUID uuid, Player onlinePlayer) { - if (triggeringUser != null && triggeringUser.getJavaUUID().equals(uuid)) { - return triggeringUser; + VotingPluginUser user = triggeringUser != null && triggeringUser.getJavaUUID().equals(uuid) + ? triggeringUser + : onlinePlayer == null + ? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true) + : plugin.getVotingPluginUserManager().getVotingPluginUser(onlinePlayer); + if (sharedDataManager() != null) { + // Reward placeholders need the published shared cache, including for the triggering user. + user.userDataFetechMode(UserDataFetchMode.DEFAULT); + } else if (user != triggeringUser) { + // Preserve direct reads for file/SQLite storage. + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); } - VotingPluginUser user = onlinePlayer == null - ? plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true) - : plugin.getVotingPluginUserManager().getVotingPluginUser(onlinePlayer); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); return user; } + + /** Returns the active shared SQL manager or null for nonshared storage. */ + private UserDataManager sharedDataManager() { + UserDataManager manager = plugin.getUserManager().getDataManager(); + return manager != null && manager.hasSharedSqlBackend() ? manager : null; + } + + /** + * Capture shared SQL reward eligibility on AdvancedCore's storage worker. + * This must finish before the VoteParty counter wipe is enqueued on that worker. + * A caller running on a platform thread must not check even a published cache + * directly because checking publication takes the cache's synchronized monitor. + */ + private void giveRewardWhenLoaded(VotingPluginUser user, boolean forceBungee, Player callbackOwner) { + UserDataManager manager = sharedDataManager(); + if (manager == null) { + voteParty.giveReward(user, forceBungee); + return; + } + UUID uuid = user.getJavaUUID(); + + // VoteParty clears per-user counters and updates the last-party timestamp + // after dispatch. Capture these decisions before that reset, not when the + // deferred reward finally runs on the player scheduler. + final boolean firstPartyToday = !plugin.getServerData().isLastVotePartySameDay(); + final int userVotesRequired = plugin.getSpecialRewardsConfig().getVotePartyUserVotesRequired(); + + // A mapped cache entry can still be an *unpublished* placeholder, so + // cacheUserIfNeeded() alone would not make reward placeholders safe to read. + Supplier populate = () -> { + if (manager.getPublishedCache(uuid) == null) manager.cacheUser(uuid, null); + if (manager.getPublishedCache(uuid) == null) return null; + // Read eligibility on the storage worker, before VoteParty resets counters. + return userVotesRequired > 0 ? user.getVotePartyVotes() : 0; + }; + Consumer success = votes -> { + if (votes == null) { + plugin.getLogger().warning("VoteParty reward not sent: shared user data is unavailable for " + uuid); + return; + } + if (votes < userVotesRequired) return; + + // Eligibility was captured on the storage worker before reset. Do not + // re-read the cache on a platform thread or re-check reset counters. + Runnable deliver = () -> givePreparedReward(user, user.isOnline(), forceBungee, firstPartyToday); + if (callbackOwner != null) { + deliver.run(); + } else { + // A worker-originated vote has no captured Player. Its completion is + // on the global platform scheduler; discover a live owner there. + Player online = Bukkit.getPlayer(uuid); + if (online == null) deliver.run(); + else plugin.getBukkitScheduler().runTask(plugin, deliver, online); + } + }; + Consumer failure = error -> plugin.getLogger() + .warning("Failed to load shared user data for VoteParty reward " + uuid + ": " + error); + + try { + if (manager.deferSharedStorageResult(populate, success, failure, callbackOwner)) return; + + // Called from a non-platform worker: storage reads are safe here, but reward + // effects still need the Bukkit/Folia scheduler. + Integer ready = populate.get(); + if (callbackOwner != null) { + plugin.getBukkitScheduler().runTask(plugin, () -> success.accept(ready), callbackOwner); + } else { + plugin.getBukkitScheduler().runTask(plugin, () -> success.accept(ready)); + } + } catch (RuntimeException | Error error) { + failure.accept(error); + } + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index d5ab29d02..b651a93bc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -10,8 +10,10 @@ import static org.mockito.Mockito.when; import java.util.ArrayList; +import java.lang.reflect.Field; import java.util.Collection; import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; import org.bukkit.Bukkit; import org.bukkit.ChatColor; @@ -27,12 +29,15 @@ import org.mockito.Mockito; import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.config.Config; import com.bencodez.votingplugin.config.SpecialRewardsConfig; import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.placeholders.PlaceHolders; import com.bencodez.votingplugin.specialrewards.voteparty.VoteParty; +import com.bencodez.votingplugin.specialrewards.voteparty.VotePartyRewardHandler; import com.bencodez.votingplugin.user.VotingPluginUser; public class VotePartyTest { @@ -190,4 +195,48 @@ public void getRandomPlayerName_NoPlayersOnline_ReturnsNoPlayer() { assertEquals("No Player", playerName); } + + @Test + public void sharedMysqlResetWaitsForQueuedRewardEligibilityReads() throws Exception { + UserDataManager dataManager = Mockito.mock(UserDataManager.class); + when(plugin.getUserManager().getDataManager()).thenReturn(dataManager); + VotePartyRewardHandler handler = Mockito.mock(VotePartyRewardHandler.class); + replaceRewardHandler(handler); + AtomicReference queuedReset = new AtomicReference<>(); + when(dataManager.deferSharedStorageWork(any(Runnable.class))).thenAnswer(invocation -> { + queuedReset.set(invocation.getArgument(0)); + return true; + }); + + voteParty.giveRewards(user, false); + + verify(handler).giveRewards(user, false); + // Voter-list state clears immediately; only the SQL counter wipe is deferred. + verify(plugin.getServerData()).setStringList(eq("VoteParty.Voted"), any()); + org.junit.jupiter.api.Assertions.assertNotNull(queuedReset.get()); + verify(plugin.getUserManager(), never()).removeAllKeyValues("VotePartyVotes", DataType.INTEGER); + + queuedReset.get().run(); + verify(plugin.getUserManager()).removeAllKeyValues("VotePartyVotes", DataType.INTEGER); + } + + @Test + public void nonsharedResetRemainsImmediate() throws Exception { + UserDataManager dataManager = Mockito.mock(UserDataManager.class); + when(plugin.getUserManager().getDataManager()).thenReturn(dataManager); + VotePartyRewardHandler handler = Mockito.mock(VotePartyRewardHandler.class); + replaceRewardHandler(handler); + + voteParty.giveRewards(user, false); + + verify(handler).giveRewards(user, false); + verify(dataManager).deferSharedStorageWork(any(Runnable.class)); + verify(plugin.getUserManager()).removeAllKeyValues("VotePartyVotes", DataType.INTEGER); + } + + private void replaceRewardHandler(VotePartyRewardHandler handler) throws Exception { + Field field = VoteParty.class.getDeclaredField("rewardHandler"); + field.setAccessible(true); + field.set(voteParty, handler); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/specialrewards/VotePartySharedMysqlRewardTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/specialrewards/VotePartySharedMysqlRewardTest.java new file mode 100644 index 000000000..563031ef6 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/specialrewards/VotePartySharedMysqlRewardTest.java @@ -0,0 +1,427 @@ +package com.bencodez.votingplugin.tests.specialrewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.SpecialRewardsConfig; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.specialrewards.voteparty.VoteParty; +import com.bencodez.votingplugin.specialrewards.voteparty.VotePartyRewardHandler; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class VotePartySharedMysqlRewardTest { + private static final UUID USER_UUID = UUID.fromString("76b1476f-c0a1-4ab6-b44a-92908a9c7ccb"); + private static final String REWARD_PATH = "VoteParty.Rewards"; + + private VotingPluginMain plugin; + private UserDataManager dataManager; + private UserManager votingUsers; + private VotingPluginUser user; + private VoteParty voteParty; + private VotePartyRewardHandler rewardHandler; + private Logger logger; + private ServerData serverData; + private RewardHandler apiRewards; + private YamlConfiguration rewardData; + private SpecialRewardsConfig config; + private MockedStatic bukkit; + + @BeforeEach + void setUp() { + plugin = mock(VotingPluginMain.class); + com.bencodez.advancedcore.api.user.UserManager coreUsers = + mock(com.bencodez.advancedcore.api.user.UserManager.class); + dataManager = mock(UserDataManager.class); + votingUsers = mock(UserManager.class); + user = mock(VotingPluginUser.class); + voteParty = mock(VoteParty.class); + logger = mock(Logger.class); + serverData = mock(ServerData.class); + apiRewards = mock(RewardHandler.class); + rewardData = new YamlConfiguration(); + config = mock(SpecialRewardsConfig.class); + + when(plugin.getUserManager()).thenReturn(coreUsers); + when(coreUsers.getDataManager()).thenReturn(dataManager); + when(plugin.getVotingPluginUserManager()).thenReturn(votingUsers); + when(plugin.getSpecialRewardsConfig()).thenReturn(config); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.getServerData()).thenReturn(serverData); + when(plugin.getRewardHandler()).thenReturn(apiRewards); + when(config.getData()).thenReturn(rewardData); + when(config.getVotePartyRewardsPath()).thenReturn(REWARD_PATH); + when(config.isVotePartyGiveOnlinePlayersOnly()).thenReturn(true); + when(voteParty.getVotedUsers()).thenReturn(Collections.singletonList(USER_UUID.toString())); + when(votingUsers.getVotingPluginUser(USER_UUID, true)).thenReturn(user); + when(user.getJavaUUID()).thenReturn(USER_UUID); + when(user.getPlugin()).thenReturn(plugin); + when(user.isOnline()).thenReturn(true); + + bukkit = mockStatic(Bukkit.class); + rewardHandler = new VotePartyRewardHandler(plugin, null, voteParty); + } + + @AfterEach + void tearDown() { + bukkit.close(); + } + + @Test + void sharedStorageLoadsUnpublishedPlaceholderBeforeReward() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + // An entry existing in the map does NOT mean that its SQL snapshot is ready. + when(dataManager.containsKey(USER_UUID)).thenReturn(true); + AtomicReference published = new AtomicReference<>(); + when(dataManager.getPublishedCache(USER_UUID)).thenAnswer(call -> published.get()); + doAnswer(call -> { + published.set(mock(UserDataCache.class)); + return null; + }).when(dataManager).cacheUser(eq(USER_UUID), isNull()); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + verify(user).userDataFetechMode(UserDataFetchMode.DEFAULT); + verify(user, never()).userDataFetechMode(UserDataFetchMode.NO_CACHE); + verify(voteParty, never()).giveReward(user, false); + + assertEquals(Integer.valueOf(0), deferred.work.get()); + verify(dataManager).cacheUser(USER_UUID, null); + verify(dataManager, never()).cacheUserIfNeeded(USER_UUID); + verify(voteParty, never()).giveReward(user, false); + + // The party timestamp changes after handler dispatch, before a queued callback. + when(serverData.isLastVotePartySameDay()).thenReturn(true); + deferred.success.accept(0); + ArgumentCaptor options = ArgumentCaptor.forClass(RewardOptions.class); + verify(apiRewards).giveReward(eq(user), eq(rewardData), eq(REWARD_PATH), options.capture()); + assertEquals("true", options.getValue().getPlaceholders().get("FirstVotePartyToday")); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void publishedSharedSnapshotIsStillReadOnStorageWorker() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.getPublishedCache(USER_UUID)).thenReturn(mock(UserDataCache.class)); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + + verify(user).userDataFetechMode(UserDataFetchMode.DEFAULT); + verify(dataManager, never()).getPublishedCache(USER_UUID); + verify(voteParty, never()).giveReward(user, false); + assertEquals(Integer.valueOf(0), deferred.work.get()); + verify(dataManager, never()).cacheUser(eq(USER_UUID), isNull()); + deferred.success.accept(0); + verify(apiRewards).giveReward(eq(user), eq(rewardData), eq(REWARD_PATH), any(RewardOptions.class)); + } + + @Test + void triggeringUserIsAlsoSwitchedFromNoCacheMode() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.getPublishedCache(USER_UUID)).thenReturn(mock(UserDataCache.class)); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", user); + + verify(user).userDataFetechMode(UserDataFetchMode.DEFAULT); + verify(user, never()).userDataFetechMode(UserDataFetchMode.NO_CACHE); + verify(votingUsers, never()).getVotingPluginUser(USER_UUID, true); + verify(dataManager, never()).getPublishedCache(USER_UUID); + assertEquals(Integer.valueOf(0), deferred.work.get()); + deferred.success.accept(0); + verify(apiRewards).giveReward(eq(user), eq(rewardData), eq(REWARD_PATH), any(RewardOptions.class)); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void fileStoragePreservesNoCacheAndImmediateDelivery() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(false); + + dispatch("giveVotedPlayers", null); + + verify(user).userDataFetechMode(UserDataFetchMode.NO_CACHE); + verify(voteParty).giveReward(user, false); + verify(dataManager, never()).getPublishedCache(USER_UUID); + } + + @Test + void missingSnapshotAfterWorkerLoadDoesNotGiveDefaultValuedReward() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + + assertNull(deferred.work.get()); + verify(dataManager).cacheUser(USER_UUID, null); + deferred.success.accept(null); + + verify(voteParty, never()).giveReward(user, false); + verify(logger).warning(contains("VoteParty reward not sent")); + } + + @Test + void perUserVoteThresholdIsEvaluatedBeforeCountersReset() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(config.getVotePartyUserVotesRequired()).thenReturn(2); + when(user.getVotePartyVotes()).thenReturn(2); + AtomicReference published = new AtomicReference<>(); + when(dataManager.getPublishedCache(USER_UUID)).thenAnswer(call -> published.get()); + doAnswer(call -> { + published.set(mock(UserDataCache.class)); + return null; + }).when(dataManager).cacheUser(eq(USER_UUID), isNull()); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + assertEquals(Integer.valueOf(2), deferred.work.get()); + when(user.getVotePartyVotes()).thenReturn(0); // VoteParty.resetUserCounts ran + deferred.success.accept(2); + + verify(user, times(1)).getVotePartyVotes(); + verify(apiRewards).giveReward(eq(user), eq(rewardData), eq(REWARD_PATH), any(RewardOptions.class)); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void ineligibleVoterIsNotRewardedAfterDeferredLoad() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(config.getVotePartyUserVotesRequired()).thenReturn(2); + when(user.getVotePartyVotes()).thenReturn(1); + AtomicReference published = new AtomicReference<>(); + when(dataManager.getPublishedCache(USER_UUID)).thenAnswer(call -> published.get()); + doAnswer(call -> { + published.set(mock(UserDataCache.class)); + return null; + }).when(dataManager).cacheUser(eq(USER_UUID), isNull()); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + assertEquals(Integer.valueOf(1), deferred.work.get()); + deferred.success.accept(1); + + verify(apiRewards, never()).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + } + + @Test + void publishedCacheInvalidatedDuringWorkerCheckDoesNotDeliver() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + UserDataCache published = mock(UserDataCache.class); + when(dataManager.getPublishedCache(USER_UUID)).thenReturn(published, null); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + assertNull(deferred.work.get()); + deferred.success.accept(null); + + verify(apiRewards, never()).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + verify(logger).warning(contains("shared user data is unavailable")); + } + + @Test + void inlineFallbackWithoutOwnerSchedulesGlobalCompletion() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.getPublishedCache(USER_UUID)).thenReturn(mock(UserDataCache.class)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + + // AdvancedCore returns false for a caller already on a storage worker. + dispatch("giveVotedPlayers", null); + org.mockito.ArgumentCaptor completion = + org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), completion.capture()); + verify(dataManager, times(2)).getPublishedCache(USER_UUID); + verify(apiRewards, never()).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + + completion.getValue().run(); + verify(apiRewards).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + } + + @Test + void inlineFallbackWithOwnerSchedulesEntityCompletion() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.getPublishedCache(USER_UUID)).thenReturn(mock(UserDataCache.class)); + Player owner = mock(Player.class); + when(owner.getUniqueId()).thenReturn(USER_UUID); + when(votingUsers.getVotingPluginUser(owner)).thenReturn(user); + when(votingUsers.getAllUUIDs()).thenReturn(new ArrayList<>()); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(owner)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + + dispatch("giveAllPlayers", null); + org.mockito.ArgumentCaptor completion = + org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), completion.capture(), eq(owner)); + verify(apiRewards, never()).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + + completion.getValue().run(); + verify(apiRewards).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + } + + @Test + void deferredCompletionWithUnknownOwnerReschedulesToOnlineEntity() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.getPublishedCache(USER_UUID)).thenReturn(mock(UserDataCache.class)); + Deferred deferred = deferSharedRead(); + Player online = mock(Player.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + + dispatch("giveVotedPlayers", null); + assertEquals(Integer.valueOf(0), deferred.work.get()); + bukkit.when(() -> Bukkit.getPlayer(USER_UUID)).thenReturn(online); + deferred.success.accept(0); + + org.mockito.ArgumentCaptor ownerTask = + org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), ownerTask.capture(), eq(online)); + verify(apiRewards, never()).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + ownerTask.getValue().run(); + verify(apiRewards).giveReward(eq(user), eq(rewardData), + eq(REWARD_PATH), any(RewardOptions.class)); + } + + @Test + void databaseFailureIsLoggedAndDoesNotDispatchReward() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + Deferred deferred = deferSharedRead(); + + dispatch("giveVotedPlayers", null); + assertNotNull(deferred.failure); + deferred.failure.accept(new IllegalStateException("database down")); + + verify(logger).warning(contains("database down")); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void workerRejectionIsLoggedWithoutDispatchingReward() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + doThrow(new RejectedExecutionException("storage worker stopped")) + .when(dataManager).deferSharedStorageResult(any(), any(), any(), + nullable(Entity.class)); + + dispatch("giveVotedPlayers", null); + + verify(logger).warning(contains("storage worker stopped")); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void votedOnlinePlayerUsesOwnerSchedulerForCompletion() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.isPlatformOwnedThread()).thenReturn(true); + Player owner = mock(Player.class); + bukkit.when(() -> Bukkit.getPlayer(USER_UUID)).thenReturn(owner); + deferSharedRead(); + + dispatch("giveVotedPlayers", null); + + verify(dataManager).deferSharedStorageResult(any(), any(), any(), eq(owner)); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void storageWorkerDoesNotLookUpBukkitPlayerForVotedRecipients() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.isPlatformOwnedThread()).thenReturn(false); + deferSharedRead(); + + dispatch("giveVotedPlayers", null); + + bukkit.verify(() -> Bukkit.getPlayer(USER_UUID), never()); + verify(dataManager).deferSharedStorageResult(any(), any(), any(), isNull()); + verify(voteParty, never()).giveReward(user, false); + } + + @Test + void giveAllPlayersAlsoDefersAndUsesOwnerScheduler() throws Exception { + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + Player owner = mock(Player.class); + when(owner.getUniqueId()).thenReturn(USER_UUID); + when(votingUsers.getVotingPluginUser(owner)).thenReturn(user); + when(votingUsers.getAllUUIDs()).thenReturn(new ArrayList<>()); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(Collections.singletonList(owner)); + deferSharedRead(); + + dispatch("giveAllPlayers", null); + + verify(user).userDataFetechMode(UserDataFetchMode.DEFAULT); + verify(dataManager).deferSharedStorageResult(any(), any(), any(), eq(owner)); + verify(voteParty, never()).giveReward(user, false); + } + + private Deferred deferSharedRead() { + Deferred deferred = new Deferred(); + doAnswer(call -> { + deferred.work = call.getArgument(0); + deferred.success = call.getArgument(1); + deferred.failure = call.getArgument(2); + return true; + }).when(dataManager).deferSharedStorageResult(any(), any(), any(), + nullable(Entity.class)); + return deferred; + } + + private void dispatch(String method, VotingPluginUser triggeringUser) throws Exception { + Method entry = VotePartyRewardHandler.class.getDeclaredMethod( + method, VotingPluginUser.class, boolean.class); + entry.setAccessible(true); + entry.invoke(rewardHandler, triggeringUser, false); + } + + private static final class Deferred { + private Supplier work; + private Consumer success; + private Consumer failure; + } +}