From 94196fc367cfadbaa3b13d55e3b7779c4e44cba2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:00:21 +0000 Subject: [PATCH] fix: complete active token deletion codes --- .../armourshop/managers/CommandManager.java | 7 +++++ .../pack/delete/ActiveTokenCodeCache.java | 25 ++++++++++++++++ .../managers/CommandManagerTest.java | 30 +++++++++++++++---- .../pack/delete/PackDeletionTest.java | 23 +++++++++++++- 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/tfminecraft/armourshop/pack/delete/ActiveTokenCodeCache.java diff --git a/src/main/java/net/tfminecraft/armourshop/managers/CommandManager.java b/src/main/java/net/tfminecraft/armourshop/managers/CommandManager.java index b4c6bbf..748f243 100644 --- a/src/main/java/net/tfminecraft/armourshop/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/armourshop/managers/CommandManager.java @@ -29,6 +29,7 @@ import net.tfminecraft.armourshop.objects.SkinCategory; import net.tfminecraft.armourshop.objects.SkinSet; import net.tfminecraft.armourshop.pack.apply.PackPullRunner; +import net.tfminecraft.armourshop.pack.delete.ActiveTokenCodeCache; import net.tfminecraft.armourshop.pack.catalog.CatalogSyncService; import net.tfminecraft.armourshop.pack.reload.DeferredIaReloadService; import net.tfminecraft.armourshop.utils.ChatMessages; @@ -537,6 +538,7 @@ private boolean handleTokenDelete(CommandSender sender, String codeArg) { + (result.error != null ? result.error : "Could not delete token.")); return; } + ActiveTokenCodeCache.invalidate(); sender.sendMessage(ChatColor.GREEN + "[ArmourShop] " + ChatColor.YELLOW + "Deleted token " + ChatColor.AQUA + toRevoke); @@ -590,6 +592,11 @@ public List onTabComplete( return filter(completions, args[0]); } + if (args.length == 3 && args[0].equalsIgnoreCase("token") + && args[1].equalsIgnoreCase("delete") && Permissions.isAdmin(sender)) { + return filter(ActiveTokenCodeCache.snapshot(), args[2]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("token")) { List completions = new ArrayList<>(); if (Permissions.isAdmin(sender)) { diff --git a/src/main/java/net/tfminecraft/armourshop/pack/delete/ActiveTokenCodeCache.java b/src/main/java/net/tfminecraft/armourshop/pack/delete/ActiveTokenCodeCache.java new file mode 100644 index 0000000..6034d98 --- /dev/null +++ b/src/main/java/net/tfminecraft/armourshop/pack/delete/ActiveTokenCodeCache.java @@ -0,0 +1,25 @@ +package net.tfminecraft.armourshop.pack.delete; + +import java.util.List; +import net.tfminecraft.armourshop.api.ProvinceSystemClient; + +/** Active unused token codes, refreshed asynchronously with the deletion cache TTL. */ +public final class ActiveTokenCodeCache { + private static final GenerationalIdCache CACHE = new GenerationalIdCache(() -> { + var result = ProvinceSystemClient.listActiveCodes(); + return result.ok + ? ProvinceSystemClient.DeletableListResult.success(result.codes.stream() + .map(entry -> entry.code).distinct().toList()) + : ProvinceSystemClient.DeletableListResult.fail(result.error); + }); + + private ActiveTokenCodeCache() {} + + public static List snapshot() { + return CACHE.snapshot(); + } + + public static void invalidate() { + CACHE.invalidate(); + } +} diff --git a/src/test/java/net/tfminecraft/armourshop/managers/CommandManagerTest.java b/src/test/java/net/tfminecraft/armourshop/managers/CommandManagerTest.java index bf018b5..9b07b04 100644 --- a/src/test/java/net/tfminecraft/armourshop/managers/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/armourshop/managers/CommandManagerTest.java @@ -121,12 +121,15 @@ private SkinCategory category(String id, SkinSet... sets) { } @Test void tokenDeletionNormalizesQuotedCodeAndReportsApiOutcomes() { - for (String code : Arrays.asList(null, " ", "\" \"")) { assertTrue(run(player, "token", "delete", code)); assertTrue(message().contains("Usage:")); } - client.when(() -> ProvinceSystemClient.revokeCode("code")).thenReturn(SimpleResult.success(), SimpleResult.fail(null), SimpleResult.fail("rejected")); - assertTrue(run(player, "token", "delete", " \" code \" ")); drain(); assertTrue(message().contains("Deleted token")); - run(player, "token", "delete", "code"); drain(); assertTrue(message().contains("Could not delete token")); - run(player, "token", "delete", "code"); drain(); assertTrue(message().contains("rejected")); - client.verify(() -> ProvinceSystemClient.revokeCode("code"), times(3)); + try (var cache = mockStatic(ActiveTokenCodeCache.class)) { + for (String code : Arrays.asList(null, " ", "\" \"")) { assertTrue(run(player, "token", "delete", code)); assertTrue(message().contains("Usage:")); } + client.when(() -> ProvinceSystemClient.revokeCode("code")).thenReturn(SimpleResult.success(), SimpleResult.fail(null), SimpleResult.fail("rejected")); + assertTrue(run(player, "token", "delete", " \" code \" ")); drain(); assertTrue(message().contains("Deleted token")); + run(player, "token", "delete", "code"); drain(); assertTrue(message().contains("Could not delete token")); + run(player, "token", "delete", "code"); drain(); assertTrue(message().contains("rejected")); + client.verify(() -> ProvinceSystemClient.revokeCode("code"), times(3)); + cache.verify(ActiveTokenCodeCache::invalidate, times(1)); + } } @Test void tokenListsUseNamesOfflineLookupAndUuidFallbacks() { @@ -283,6 +286,21 @@ private SkinCategory category(String id, SkinSet... sets) { when(command.getName()).thenReturn("other"); assertTrue(manager.onTabComplete(console, command, "as", new String[]{""}).isEmpty()); } + @Test void tokenDeletionCompletesOnlyCachedCodesForAdminsOnTheExactPath() { + try (var cache = mockStatic(ActiveTokenCodeCache.class)) { + cache.when(ActiveTokenCodeCache::snapshot).thenReturn(List.of("Alpha123", "Beta456")); + assertEquals(List.of("Alpha123", "Beta456"), manager.onTabComplete(console, command, "as", new String[]{"token", "delete", ""})); + assertEquals(List.of("Alpha123"), manager.onTabComplete(console, command, "as", new String[]{"TOKEN", "DELETE", "aLp"})); + assertEquals(List.of(), manager.onTabComplete(console, command, "as", new String[]{"token", "delete", "unknown"})); + cache.clearInvocations(); + for (String[] args : List.of(new String[]{"token", "create", ""}, new String[]{"wrong", "delete", ""}, new String[]{"token", "delete", "Alpha123", ""})) + assertEquals(List.of(), manager.onTabComplete(console, command, "as", args)); + assertEquals(List.of(), manager.onTabComplete(mock(CommandSender.class), command, "as", new String[]{"token", "delete", ""})); + cache.verifyNoInteractions(); + client.verifyNoInteractions(); + } + } + @Test void tabCompletionUsesLocaleIndependentIdentifiers() { var cat = category("ITEMS"); categories.when(CategoryLoader::get).thenReturn(List.of(cat)); Locale old = Locale.getDefault(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); assertEquals(List.of("ITEMS"), manager.onTabComplete(console, command, "as", new String[]{"model", "apply", "i"})); } diff --git a/src/test/java/net/tfminecraft/armourshop/pack/delete/PackDeletionTest.java b/src/test/java/net/tfminecraft/armourshop/pack/delete/PackDeletionTest.java index b8e69e6..9a23a14 100644 --- a/src/test/java/net/tfminecraft/armourshop/pack/delete/PackDeletionTest.java +++ b/src/test/java/net/tfminecraft/armourshop/pack/delete/PackDeletionTest.java @@ -51,7 +51,7 @@ private record CacheField(Object owner, Field field) {} when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(i -> {workers.add(i.getArgument(1)); return mock(BukkitTask.class);}); when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(i -> {main.add(i.getArgument(1)); return mock(BukkitTask.class);}); client = mockStatic(ProvinceSystemClient.class); client.when(() -> ProvinceSystemClient.revokeSubmission(anyString())).thenReturn(SimpleResult.success()); - for (Class type : List.of(DeletableSubmissionCache.class, DeletableStaffSkinCache.class)) { + for (Class type : List.of(DeletableSubmissionCache.class, DeletableStaffSkinCache.class, ActiveTokenCodeCache.class)) { Object cache = field(type, "CACHE").get(null); for (String name : List.of("IDS", "FETCHED_AT", "refreshInFlight", "generation")) { Field field = field(GenerationalIdCache.class, name); @@ -171,6 +171,27 @@ private void invalidationDuringFetch(boolean staff, boolean fail) throws Excepti } } + @Test void activeTokenCodesRefreshOnlyOnWorkersAndDropRevokedCodesAfterInvalidation() { + var alpha = new ActiveCode("Alpha123", null, null, null, null); + var beta = new ActiveCode("Beta456", null, null, null, null); + client.when(ProvinceSystemClient::listActiveCodes).thenReturn(ActiveCodesResult.success(List.of(alpha, beta, alpha))); + assertTrue(ActiveTokenCodeCache.snapshot().isEmpty()); + assertTrue(ActiveTokenCodeCache.snapshot().isEmpty()); + assertEquals(1, workers.size()); client.verifyNoInteractions(); + workers.remove().run(); + assertEquals(List.of("Alpha123", "Beta456"), ActiveTokenCodeCache.snapshot()); + assertTrue(workers.isEmpty()); + client.when(ProvinceSystemClient::listActiveCodes).thenReturn(ActiveCodesResult.success(List.of(beta))); + ActiveTokenCodeCache.invalidate(); workers.remove().run(); + assertEquals(List.of("Beta456"), ActiveTokenCodeCache.snapshot()); + client.when(ProvinceSystemClient::listActiveCodes).thenReturn(ActiveCodesResult.fail("offline")); + ActiveTokenCodeCache.invalidate(); workers.remove().run(); + assertEquals(List.of("Beta456"), ActiveTokenCodeCache.snapshot()); + client.when(ProvinceSystemClient::listActiveCodes).thenReturn(ActiveCodesResult.success(List.of())); + workers.remove().run(); + assertTrue(ActiveTokenCodeCache.snapshot().isEmpty()); + } + @Test void cacheSnapshotsCannotBeMutatedByTabCompleters() { client.when(ProvinceSystemClient::listDeletableSubmissionIds).thenReturn(DeletableListResult.success(List.of("one"))); client.when(ProvinceSystemClient::listDeletableStaffSkinIds).thenReturn(DeletableListResult.success(List.of("one")));