From a2d88cb2cfcbab2e4bd267e14e8fe84cb305633c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:16:38 -0600 Subject: [PATCH 1/2] Cover proxy vote delay and replay classification regressions --- .../BackendProxyVoteDelayReplayTest.java | 309 ++++++++++++++++++ .../core/vote/SharedVoteProcessorTest.java | 91 ++++++ .../tests/VotingPluginProxyDelayGateTest.java | 96 ++++++ 3 files changed, 496 insertions(+) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyDelayGateTest.java diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java new file mode 100644 index 0000000000..22558441d3 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java @@ -0,0 +1,309 @@ +package com.bencodez.votingplugin.backendproxy.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.api.user.validation.UserValidationResult; +import com.bencodez.advancedcore.api.user.validation.ValidationSource; +import com.bencodez.advancedcore.api.user.validation.ValidationStatus; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; +import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; +import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; +import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; +import com.bencodez.votingplugin.events.PlayerPostVoteEvent; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.listeners.PlayerVoteListener; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.VoteSite; + +/** Exercises wire decoding, durable deduplication, event metadata and the real listener together. */ +class BackendProxyVoteDelayReplayTest { + private static final UUID PLAYER_ID = UUID.fromString("d4463458-69f3-4c2f-b8c1-d57d187da516"); + private static final String SERVICE = "known.example"; + private static final long RECORDED_TIME = 1_788_201_600_000L; + + @TempDir + Path directory; + + @TestFactory + Stream modernLiveVoteCannotReuseUpstreamTimestampToBypassBackendDelay() { + return routes(targeted -> { + Fixture fixture = fixture(); + JsonEnvelope live = vote(UUID.randomUUID(), RECORDED_TIME, targeted, false, true); + + fixture.deliver(live); + fixture.deliver(live); + + fixture.assertEffects(0); + verify(fixture.site, times(1)).giveWaitUntilVoteDelayRewards(fixture.user, true, true); + assertEquals(RECORDED_TIME, fixture.lastVote.get()); + }); + } + + @TestFactory + Stream identifiedQueuedVoteExecutesOnceDespiteNewerUpstreamLastVotesAndRestart() { + return routes(targeted -> { + Fixture fixture = fixture(); + UUID voteId = UUID.randomUUID(); + JsonEnvelope queued = vote(voteId, RECORDED_TIME - 60_000L, targeted, true, true); + + fixture.deliver(queued); + fixture.deliver(queued); + fixture.restartReceiver(); + fixture.deliver(queued); + + fixture.assertEffects(1); + fixture.assertOccurrence(0, voteId, RECORDED_TIME - 60_000L); + verify(fixture.site, never()).giveWaitUntilVoteDelayRewards(any(), anyBoolean(), anyBoolean()); + assertTrue(new ProcessedVoteCache(fixture.receipts).hasDurableReceipt(voteId)); + }); + } + + @TestFactory + Stream sameTimestampWithDifferentQueuedIdsRepresentsDistinctAcceptedOccurrences() { + return routes(targeted -> { + Fixture fixture = fixture(); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + JsonEnvelope firstVote = vote(first, RECORDED_TIME, targeted, true, true); + JsonEnvelope secondVote = vote(second, RECORDED_TIME, targeted, true, true); + + fixture.deliver(firstVote); + fixture.deliver(secondVote); + fixture.deliver(firstVote); + fixture.restartReceiver(); + fixture.deliver(secondVote); + + fixture.assertEffects(2); + fixture.assertOccurrence(0, first, RECORDED_TIME); + fixture.assertOccurrence(1, second, RECORDED_TIME); + }); + } + + @TestFactory + Stream changedTimestampDoesNotTurnACompletedIdIntoAnotherVote() { + return routes(targeted -> { + Fixture fixture = fixture(); + UUID voteId = UUID.randomUUID(); + fixture.deliver(vote(voteId, RECORDED_TIME, targeted, true, true)); + fixture.deliver(vote(voteId, RECORDED_TIME + 60_000L, targeted, true, true)); + fixture.restartReceiver(); + fixture.deliver(vote(voteId, RECORDED_TIME - 60_000L, targeted, true, true)); + + fixture.assertEffects(1); + fixture.assertOccurrence(0, voteId, RECORDED_TIME); + assertEquals(RECORDED_TIME, fixture.lastVote.get()); + }); + } + + @TestFactory + Stream outOfOrderQueuedVotesKeepTheirIdsAndDoNotReplayEffects() { + return routes(targeted -> { + Fixture fixture = fixture(); + UUID older = UUID.randomUUID(); + UUID newer = UUID.randomUUID(); + JsonEnvelope olderVote = vote(older, RECORDED_TIME - 60_000L, targeted, true, true); + JsonEnvelope newerVote = vote(newer, RECORDED_TIME + 60_000L, targeted, true, true); + + fixture.deliver(newerVote); + fixture.deliver(olderVote); + fixture.deliver(newerVote); + fixture.restartReceiver(); + fixture.deliver(olderVote); + + fixture.assertEffects(2); + fixture.assertOccurrence(0, newer, RECORDED_TIME + 60_000L); + fixture.assertOccurrence(1, older, RECORDED_TIME - 60_000L); + }); + } + + @Test + void legacyUnclassifiedBacklogKeepsTimestampFallbackAndDurableIdentity() throws Exception { + Fixture fixture = fixture(); + UUID voteId = UUID.randomUUID(); + JsonEnvelope legacy = VotingPluginWire.legacyUnclassifiedDelivery( + vote(voteId, RECORDED_TIME, true, false, false)); + + fixture.deliver(legacy); + fixture.restartReceiver(); + fixture.deliver(legacy); + + fixture.assertEffects(1); + fixture.assertOccurrence(0, voteId, RECORDED_TIME); + verify(fixture.site, never()).giveWaitUntilVoteDelayRewards(any(), anyBoolean(), anyBoolean()); + } + + @Test + void missingQueuedIdentityStillChecksDelayWhileLegacyMissingIdentityRemainsCompatible() throws Exception { + Fixture fixture = fixture(); + // Missing IDs retain legacy delivery semantics; do not request reliable acknowledgement. + JsonEnvelope current = VotingPluginWire.queuedDelivery(VotingPluginWire.voteOnline("Player", + PLAYER_ID.toString(), SERVICE, RECORDED_TIME, true, true, "", null, false, false, 1, 1, true)); + fixture.deliver(current); + fixture.assertEffects(0); + verify(fixture.site).giveWaitUntilVoteDelayRewards(fixture.user, true, true); + + JsonEnvelope legacy = VotingPluginWire.legacyUnclassifiedDelivery(VotingPluginWire.voteOnline("Player", + PLAYER_ID.toString(), SERVICE, RECORDED_TIME, true, true, "", null, false, false, 1, 1)); + fixture.deliver(legacy); + fixture.assertEffects(1); + } + + private Fixture fixture() throws Exception { + return new Fixture(directory.resolve(UUID.randomUUID() + ".receipts")); + } + + private Stream routes(RouteTest test) { + return Stream.of(false, true).map(targeted -> DynamicTest.dynamicTest( + targeted ? "VoteOnline selected backend" : "Vote all backends", () -> test.run(targeted))); + } + + @FunctionalInterface + private interface RouteTest { + void run(boolean targeted) throws Exception; + } + + private static JsonEnvelope vote(UUID voteId, long time, boolean targeted, boolean queued, boolean validated) { + JsonEnvelope envelope; + if (validated) { + envelope = targeted + ? VotingPluginWire.voteOnline("Player", PLAYER_ID.toString(), SERVICE, time, + true, true, "", voteId, false, false, 1, 1, true) + : VotingPluginWire.vote("Player", PLAYER_ID.toString(), SERVICE, time, + true, true, "", voteId, false, false, 1, 1, true); + } else { + envelope = targeted + ? VotingPluginWire.voteOnline("Player", PLAYER_ID.toString(), SERVICE, time, + true, true, "", voteId, false, false, 1, 1) + : VotingPluginWire.vote("Player", PLAYER_ID.toString(), SERVICE, time, + true, true, "", voteId, false, false, 1, 1); + } + if (queued) envelope = VotingPluginWire.queuedDelivery(envelope); + return VotingPluginWire.requestVoteDeliveryAcknowledgement(envelope); + } + + private static final class Fixture { + private final Path receipts; + private final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + private final VotingPluginUser user = mock(VotingPluginUser.class); + private final VoteSite site = mock(VoteSite.class); + private final AtomicLong lastVote = new AtomicLong(RECORDED_TIME); + private final AtomicBoolean canVote = new AtomicBoolean(false); + private final List completed = new ArrayList<>(); + private BackendProxyMessageRouter router; + + private Fixture(Path receipts) throws Exception { + this.receipts = receipts; + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + when(plugin.getUserManager().getProperName("Player")).thenReturn("Player"); + when(plugin.getUserManager().getValidationService().validate("Player", false)).thenReturn( + new UserValidationResult(ValidationStatus.VALID, "Player", ValidationSource.STORAGE, "ok", false)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_ID, "Player")).thenReturn(user); + when(plugin.getVoteSiteManager().getVoteSite(SERVICE, true)).thenReturn(site); + when(site.isEnabled()).thenReturn(true); + when(site.isWaitUntilVoteDelay()).thenReturn(true); + when(site.getServiceSite()).thenReturn(SERVICE); + when(site.getKey()).thenReturn("Example"); + when(user.isOnline()).thenReturn(true); + when(user.getPlayerName()).thenReturn("Player"); + when(user.getUUID()).thenReturn(PLAYER_ID.toString()); + when(user.getJavaUUID()).thenReturn(PLAYER_ID); + when(user.getTime(site)).thenAnswer(ignored -> lastVote.get()); + when(user.canVoteSite(site)).thenAnswer(ignored -> canVote.get()); + doAnswer(call -> { + lastVote.set(call.getArgument(1)); + canVote.set(false); + return null; + }).when(user).setTime(eq(site), anyLong()); + + // Run the production event adapter without constructing a storage-backed user. + var pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + doCallRealMethod().when(user).bungeeVotePluginMessaging(any(), anyLong(), any(VoteTotalsSnapshot.class), + anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), + anyBoolean(), any(), anyBoolean()); + PlayerVoteListener listener = new PlayerVoteListener(plugin); + var eventManager = plugin.getServer().getPluginManager(); + doAnswer(call -> { + Object event = call.getArgument(0); + if (event instanceof PlayerVoteEvent vote) listener.onplayerVote(vote); + if (event instanceof PlayerPostVoteEvent post) completed.add(post); + return null; + }).when(eventManager).callEvent(any()); + restartReceiver(); + } + + private void restartReceiver() { + router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), + mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), + new ProcessedVoteCache(receipts)); + } + + private void deliver(JsonEnvelope envelope) { + AtomicReference outcome = new AtomicReference<>(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + } + + private void assertOccurrence(int index, UUID voteId, long time) { + PlayerPostVoteEvent post = completed.get(index); + assertEquals(voteId, post.getVoteUUID()); + assertEquals(time, post.getVoteTime()); + verify(plugin.getVoteStreakHandler()).processVote(user, time, voteId); + } + + private void assertEffects(int count) { + assertEquals(count, completed.size()); + verify(user, times(count)).setTime(eq(site), anyLong()); + verify(user, times(count)).playerVote(site, true, true); + verify(user, times(count)).addTotal(); + verify(user, times(count)).addTotalDaily(); + verify(user, times(count)).addTotalWeekly(); + verify(user, times(count)).addPoints(); + verify(user, never()).addPoints(anyInt()); + verify(user, times(count)).checkDayVoteStreak(true); + verify(plugin.getVoteParty(), times(count)).vote(user, true, true, true); + verify(plugin.getCoolDownCheck(), times(count)).vote(user, site); + verify(plugin.getVoteStreakHandler(), times(count)).processVote(eq(user), anyLong(), any(UUID.class)); + verify(plugin.getVoteMilestonesManager(), times(count)) + .handleVote(eq(user), any(VoteTotalsSnapshot.class), eq(true), any(UUID.class), any()); + verify(user, never()).addOfflineVote(any()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index d0fb241ebb..8baf0730e7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -9,12 +9,17 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.UUID; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; @@ -404,4 +409,90 @@ void offlineQueueLimitPreservesUncachedPostEvent() { verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(457L), any(UUID.class), eq(false)); verify(ops).addPoints(user); } + + @TestFactory + Stream delayAuthorizationDoesNotConfuseTimestampsWithQueuedIdentity() { + record Delivery(String name, boolean proxy, boolean queued, boolean queueKnown, + boolean delayKnown, boolean identified, long incoming, long stored, boolean accepted) { } + return List.of( + new Delivery("direct matching timestamp", false, false, false, false, true, 321L, 321L, false), + new Delivery("direct with proxy queue flags", false, true, true, true, true, 321L, 321L, false), + new Delivery("modern live matching timestamp", true, false, true, false, true, 321L, 321L, false), + new Delivery("upstream validated live matching timestamp", true, false, true, true, true, 321L, 321L, false), + new Delivery("queued without identity", true, true, true, true, false, 321L, 321L, false), + new Delivery("explicit validation without queue classification", true, false, false, true, true, 321L, 321L, false), + new Delivery("identified queue matching timestamp", true, true, true, true, true, 321L, 321L, true), + new Delivery("identified queue older than LastVotes", true, true, true, true, true, 321L, 999L, true), + new Delivery("identified queue newer than LastVotes", true, true, true, true, true, 999L, 321L, true), + new Delivery("legacy missing identity matching timestamp", true, false, false, false, false, 321L, 321L, true), + new Delivery("legacy identified matching timestamp", true, false, false, false, true, 321L, 321L, true), + new Delivery("legacy mismatched timestamp", true, false, false, false, true, 320L, 321L, false), + new Delivery("legacy zero timestamp", true, false, false, false, false, 0L, 0L, false)) + .stream().map(delivery -> DynamicTest.dynamicTest(delivery.name(), () -> { + var ops = accepted(); + UUID voteId = delivery.identified() ? UUID.randomUUID() : null; + when(ops.proxyVote()).thenReturn(delivery.proxy()); + when(ops.proxyVoteId()).thenReturn(voteId); + when(ops.identifiedQueuedProxyVote()).thenReturn(delivery.queued()); + when(ops.proxyQueueClassificationKnown()).thenReturn(delivery.queueKnown()); + when(ops.proxyDelayValidationKnown()).thenReturn(delivery.delayKnown()); + when(ops.incomingTime()).thenReturn(delivery.incoming()); + when(ops.lastVoteTime(user, site)).thenReturn(delivery.stored()); + when(ops.waitUntilVoteDelay(site)).thenReturn(true); + when(ops.canVoteSite(user, site)).thenReturn(false); + when(ops.userOnline(user)).thenReturn(true); + when(ops.wasOnline()).thenReturn(true); + when(ops.processRewards()).thenReturn(true); + + SharedVoteProcessor.process(ops); + + assertAcceptedEffects(ops, delivery.accepted() ? 1 : 0); + verify(ops, times(delivery.accepted() ? 0 : 1)) + .giveWaitRewards(site, user, true, delivery.proxy()); + if (delivery.accepted() && voteId != null) { + verify(ops).voteStreak(user, delivery.incoming(), voteId); + verify(ops).postVote(site, user, "Ben", delivery.incoming(), voteId, false); + } + })); + } + + @TestFactory + Stream directVotesKeepDocumentedDelayExceptions() { + return Stream.of("delay disabled", "delay elapsed", "fake vote", "bypass permission") + .map(exception -> DynamicTest.dynamicTest(exception, () -> { + var ops = accepted(); + when(ops.waitUntilVoteDelay(site)).thenReturn(!exception.equals("delay disabled")); + when(ops.canVoteSite(user, site)).thenReturn(exception.equals("delay elapsed")); + when(ops.realVote()).thenReturn(!exception.equals("fake vote")); + when(ops.bypassWaitPermission(user)).thenReturn(exception.equals("bypass permission")); + when(ops.userOnline(user)).thenReturn(true); + when(ops.processRewards()).thenReturn(true); + + SharedVoteProcessor.process(ops); + + verify(ops).playerVote(user, site, true, false); + verify(ops).cooldown(user, site); + verify(ops).postVote(eq(site), eq(user), eq("Ben"), anyLong(), any(UUID.class), eq(false)); + verify(ops, never()).giveWaitRewards(any(), any(), anyBoolean(), anyBoolean()); + })); + } + + private void assertAcceptedEffects(SharedVoteProcessor.Operations ops, int count) { + verify(ops, times(count)).cache(user); + verify(ops, times(count)).updateName(user); + verify(ops, times(count)).voteParty(eq(user), anyBoolean(), anyBoolean(), anyBoolean()); + verify(ops, times(count)).setTime(eq(user), eq(site), anyLong()); + verify(ops, times(count)).playerVote(eq(user), eq(site), anyBoolean(), anyBoolean()); + verify(ops, never()).addOfflineVote(any(), any()); + verify(ops, times(count)).addTotal(user); + verify(ops, times(count)).addTotalDaily(user); + verify(ops, times(count)).addTotalWeekly(user); + verify(ops, times(count)).addPoints(user); + verify(ops, times(count)).checkDayVoteStreak(eq(user), anyBoolean()); + verify(ops, times(count)).milestones(eq(user), any(UUID.class), anyBoolean()); + verify(ops, times(count)).cooldown(user, site); + verify(ops, times(count)).voteStreak(eq(user), anyLong(), any(UUID.class)); + verify(ops, times(count)).postVote(eq(site), eq(user), eq("Ben"), anyLong(), any(UUID.class), eq(false)); + verify(ops, times(count)).setUpdate(); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyDelayGateTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyDelayGateTest.java new file mode 100644 index 0000000000..8d8ac6397d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyDelayGateTest.java @@ -0,0 +1,96 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import com.bencodez.advancedcore.bungeeapi.time.BungeeTimeChecker; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; + +class VotingPluginProxyDelayGateTest { + private static final String PLAYER_ID = "00000000-0000-0000-0000-000000000001"; + private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 1, 12, 0); + + @TestFactory + Stream withinWaitUntilVoteDelayUsesNewestMatchingOccurrenceAcrossPendingAndStoredVotes() { + return Stream.of("online", "server", "rollover", "stored") + .flatMap(source -> Stream.of(false, true).map(reverse -> DynamicTest.dynamicTest( + source + (reverse ? " newest first" : " oldest first"), () -> { + VotingPluginProxyTestImpl proxy = spy(new VotingPluginProxyTestImpl()); + VoteCacheHandler cache = mock(VoteCacheHandler.class); + BungeeTimeChecker clock = mock(BungeeTimeChecker.class); + doReturn(cache).when(proxy).getVoteCacheHandler(); + doReturn(clock).when(proxy).getBungeeTimeChecker(); + when(clock.getTime()).thenReturn(NOW); + when(proxy.getConfig().getWaitUntilVoteDelaySites()).thenReturn(List.of("Site")); + when(proxy.getConfig().getWaitUntilVoteDelayService("Site")).thenReturn("Service"); + when(proxy.getConfig().getWaitUntilVoteDelayVoteDelay("Site")).thenReturn(1); + + long oldTime = millis(NOW.minusHours(2)); + long recentTime = millis(NOW.minusMinutes(30)); + List votes = new ArrayList<>(List.of( + vote(PLAYER_ID, "Service", oldTime), vote(PLAYER_ID, "Service", recentTime))); + if (reverse) Collections.reverse(votes); + // A different service must not reserve this site's delay slot. + votes.add(vote(PLAYER_ID, "OtherService", millis(NOW.plusDays(1)))); + when(cache.hasOnlineVotes(PLAYER_ID)).thenReturn(source.equals("online")); + when(cache.getOnlineVotes(PLAYER_ID)).thenReturn(new ArrayList<>(votes)); + ArrayList serverVotes = source.equals("server") + ? new ArrayList<>(votes) : new ArrayList<>(); + serverVotes.add(vote("another-player", "Service", millis(NOW.plusDays(1)))); + when(cache.getVotes("Server1")).thenReturn(serverVotes); + when(cache.getVotes("Server2")).thenReturn(new ArrayList<>()); + + ConcurrentLinkedQueue rollover = new ConcurrentLinkedQueue<>(); + if (source.equals("rollover")) { + for (OfflineBungeeVote vote : votes) { + rollover.add(new VoteTimeQueue(vote.getVoteId(), "Player", vote.getService(), vote.getTime())); + } + } + rollover.add(new VoteTimeQueue(UUID.randomUUID(), "OtherPlayer", "Service", millis(NOW.plusDays(1)))); + when(cache.getTimeChangeQueue()).thenReturn(rollover); + ArrayList stored = new ArrayList<>(); + if (source.equals("stored")) { + String times = reverse ? recentTime + "%line%Site//" + oldTime + : oldTime + "%line%Site//" + recentTime; + stored.add(new Column("LastVotes", new DataValueString( + "Site//" + times + "%line%OtherSite//" + millis(NOW.plusDays(1))))); + } + + assertFalse(proxy.checkVoteDelay(PLAYER_ID, "Player", "service", stored, true)); + assertTrue(proxy.checkVoteDelay(PLAYER_ID, "Player", "UnconfiguredService", stored, true)); + if (source.equals("rollover")) { + assertTrue(proxy.checkVoteDelay(PLAYER_ID, "Player", "Service", stored, false)); + } + when(clock.getTime()).thenReturn(NOW.plusHours(2)); + assertTrue(proxy.checkVoteDelay(PLAYER_ID, "Player", "Service", stored, true)); + }))); + } + + private static OfflineBungeeVote vote(String playerId, String service, long time) { + return new OfflineBungeeVote(UUID.randomUUID(), "Player", playerId, service, time, true, ""); + } + + private static long millis(LocalDateTime time) { + return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } +} From 2236dea0ff7e5d9e1be91999cfe86af8604d62d4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:48:31 -0600 Subject: [PATCH 2/2] Use deterministic proxy vote regression identities --- .../BackendProxyVoteDelayReplayTest.java | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java index 22558441d3..8d018eb6e2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyVoteDelayReplayTest.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; @@ -51,9 +52,13 @@ /** Exercises wire decoding, durable deduplication, event metadata and the real listener together. */ class BackendProxyVoteDelayReplayTest { private static final UUID PLAYER_ID = UUID.fromString("d4463458-69f3-4c2f-b8c1-d57d187da516"); + private static final UUID FIRST_VOTE_ID = UUID.fromString("00000000-0000-4000-8000-000000000001"); + private static final UUID SECOND_VOTE_ID = UUID.fromString("00000000-0000-4000-8000-000000000002"); private static final String SERVICE = "known.example"; private static final long RECORDED_TIME = 1_788_201_600_000L; + private final AtomicInteger fixtureSequence = new AtomicInteger(); + @TempDir Path directory; @@ -61,7 +66,7 @@ class BackendProxyVoteDelayReplayTest { Stream modernLiveVoteCannotReuseUpstreamTimestampToBypassBackendDelay() { return routes(targeted -> { Fixture fixture = fixture(); - JsonEnvelope live = vote(UUID.randomUUID(), RECORDED_TIME, targeted, false, true); + JsonEnvelope live = vote(FIRST_VOTE_ID, RECORDED_TIME, targeted, false, true); fixture.deliver(live); fixture.deliver(live); @@ -76,7 +81,7 @@ Stream modernLiveVoteCannotReuseUpstreamTimestampToBypassBackendDel Stream identifiedQueuedVoteExecutesOnceDespiteNewerUpstreamLastVotesAndRestart() { return routes(targeted -> { Fixture fixture = fixture(); - UUID voteId = UUID.randomUUID(); + UUID voteId = FIRST_VOTE_ID; JsonEnvelope queued = vote(voteId, RECORDED_TIME - 60_000L, targeted, true, true); fixture.deliver(queued); @@ -95,8 +100,8 @@ Stream identifiedQueuedVoteExecutesOnceDespiteNewerUpstreamLastVote Stream sameTimestampWithDifferentQueuedIdsRepresentsDistinctAcceptedOccurrences() { return routes(targeted -> { Fixture fixture = fixture(); - UUID first = UUID.randomUUID(); - UUID second = UUID.randomUUID(); + UUID first = FIRST_VOTE_ID; + UUID second = SECOND_VOTE_ID; JsonEnvelope firstVote = vote(first, RECORDED_TIME, targeted, true, true); JsonEnvelope secondVote = vote(second, RECORDED_TIME, targeted, true, true); @@ -116,7 +121,7 @@ Stream sameTimestampWithDifferentQueuedIdsRepresentsDistinctAccepte Stream changedTimestampDoesNotTurnACompletedIdIntoAnotherVote() { return routes(targeted -> { Fixture fixture = fixture(); - UUID voteId = UUID.randomUUID(); + UUID voteId = FIRST_VOTE_ID; fixture.deliver(vote(voteId, RECORDED_TIME, targeted, true, true)); fixture.deliver(vote(voteId, RECORDED_TIME + 60_000L, targeted, true, true)); fixture.restartReceiver(); @@ -132,8 +137,8 @@ Stream changedTimestampDoesNotTurnACompletedIdIntoAnotherVote() { Stream outOfOrderQueuedVotesKeepTheirIdsAndDoNotReplayEffects() { return routes(targeted -> { Fixture fixture = fixture(); - UUID older = UUID.randomUUID(); - UUID newer = UUID.randomUUID(); + UUID older = FIRST_VOTE_ID; + UUID newer = SECOND_VOTE_ID; JsonEnvelope olderVote = vote(older, RECORDED_TIME - 60_000L, targeted, true, true); JsonEnvelope newerVote = vote(newer, RECORDED_TIME + 60_000L, targeted, true, true); @@ -152,7 +157,7 @@ Stream outOfOrderQueuedVotesKeepTheirIdsAndDoNotReplayEffects() { @Test void legacyUnclassifiedBacklogKeepsTimestampFallbackAndDurableIdentity() throws Exception { Fixture fixture = fixture(); - UUID voteId = UUID.randomUUID(); + UUID voteId = FIRST_VOTE_ID; JsonEnvelope legacy = VotingPluginWire.legacyUnclassifiedDelivery( vote(voteId, RECORDED_TIME, true, false, false)); @@ -182,7 +187,7 @@ void missingQueuedIdentityStillChecksDelayWhileLegacyMissingIdentityRemainsCompa } private Fixture fixture() throws Exception { - return new Fixture(directory.resolve(UUID.randomUUID() + ".receipts")); + return new Fixture(directory.resolve("fixture-" + fixtureSequence.incrementAndGet() + ".receipts")); } private Stream routes(RouteTest test) {