diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java index 46b96d83..bbf174a8 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/Friends.java @@ -5,7 +5,7 @@ import nl.hauntedmc.proxyfeatures.api.io.config.ConfigMap; import nl.hauntedmc.proxyfeatures.api.io.localization.MessageMap; import nl.hauntedmc.proxyfeatures.features.VelocityBaseFeature; -import nl.hauntedmc.proxyfeatures.features.friends.command.FriendCommand; +import nl.hauntedmc.proxyfeatures.features.friends.command.AsyncFriendCommand; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendRelationEntity; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendSettingsEntity; import nl.hauntedmc.proxyfeatures.features.friends.entity.FriendsService; @@ -118,7 +118,7 @@ public void initialize() { service = new FriendsService(this, cache); getLifecycleManager().getCommandManager() - .registerFeatureCommand(new FriendCommand(this)); + .registerFeatureCommand(new AsyncFriendCommand(this)); getLifecycleManager().getListenerManager().registerListener(new FriendActivityListener(this)); } @@ -138,5 +138,4 @@ public FriendsService getService() { public FriendsCache getCache() { return cache; } - } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java new file mode 100644 index 00000000..8dd56431 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/friends/command/AsyncFriendCommand.java @@ -0,0 +1,197 @@ +package nl.hauntedmc.proxyfeatures.features.friends.command; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; +import nl.hauntedmc.proxyfeatures.features.friends.Friends; + +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +/** + * Keeps database-backed friend command work away from Velocity's command and Netty threads. + */ +public final class AsyncFriendCommand implements FeatureCommand { + + private static final Set NO_ARGUMENT_SUBCOMMANDS = Set.of( + "acceptall", "denyall", "disable", "enable" + ); + private static final Set SINGLE_ARGUMENT_SUBCOMMANDS = Set.of( + "add", "remove", "accept", "deny", "cancel", "server", "block", "unblock" + ); + private static final Set OPTIONAL_PAGE_SUBCOMMANDS = Set.of("list", "requests"); + + private final Friends feature; + private final FriendCommand delegate; + + public AsyncFriendCommand(Friends feature) { + this.feature = feature; + this.delegate = new FriendCommand(feature); + } + + @Override + public void execute(Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player)) { + delegate.execute(invocation); + return; + } + + String alias = invocation.alias(); + String[] arguments = invocation.arguments().clone(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + delegate.execute(capturedInvocation(source, alias, arguments)); + } catch (RuntimeException exception) { + logFailure("Command execution", exception); + sendDataUnavailable(source); + } + }); + } catch (RuntimeException exception) { + logFailure("Command scheduling", exception); + sendDataUnavailable(source); + } + } + + @Override + public boolean hasPermission(Invocation invocation) { + return delegate.hasPermission(invocation); + } + + @Override + public CompletableFuture> suggestAsync(Invocation invocation) { + CommandSource source = invocation.source(); + if (!(source instanceof Player)) { + return CompletableFuture.completedFuture(List.of()); + } + + String[] arguments = invocation.arguments().clone(); + if (hasNoFurtherArguments(arguments)) { + return CompletableFuture.completedFuture(List.of()); + } + + // Pure subcommand filtering is safe synchronously. Numeric first arguments query friend data + // for page counts and therefore must use the background path as well. + if (arguments.length == 0 + || (arguments.length == 1 && !isPositiveInteger(arguments[0]))) { + try { + CompletableFuture> result = delegate.suggestAsync(invocation); + return result == null ? CompletableFuture.completedFuture(List.of()) : result; + } catch (RuntimeException exception) { + logFailure("Tab completion", exception); + return CompletableFuture.completedFuture(List.of()); + } + } + + String alias = invocation.alias(); + CompletableFuture> result = new CompletableFuture<>(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + CompletableFuture> delegated = + delegate.suggestAsync(capturedInvocation(source, alias, arguments)); + if (delegated == null) { + result.complete(List.of()); + return; + } + delegated.whenComplete((suggestions, failure) -> { + if (failure != null) { + logFailure("Tab completion", failure); + result.complete(List.of()); + } else { + result.complete(suggestions == null ? List.of() : suggestions); + } + }); + } catch (RuntimeException exception) { + logFailure("Tab completion", exception); + result.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logFailure("Tab-completion scheduling", exception); + result.complete(List.of()); + } + return result; + } + + static boolean hasNoFurtherArguments(String[] arguments) { + if (arguments.length == 0) { + return false; + } + + String subcommand = arguments[0].toLowerCase(Locale.ROOT); + if (NO_ARGUMENT_SUBCOMMANDS.contains(subcommand)) { + return arguments.length >= 2; + } + if (SINGLE_ARGUMENT_SUBCOMMANDS.contains(subcommand)) { + return arguments.length > 2; + } + if (OPTIONAL_PAGE_SUBCOMMANDS.contains(subcommand)) { + return arguments.length > 2; + } + return false; + } + + private static boolean isPositiveInteger(String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + private static SimpleCommand.Invocation capturedInvocation( + CommandSource source, + String alias, + String[] arguments + ) { + return new SimpleCommand.Invocation() { + @Override + public CommandSource source() { + return source; + } + + @Override + public String[] arguments() { + return arguments; + } + + @Override + public String alias() { + return alias; + } + }; + } + + private void sendDataUnavailable(CommandSource source) { + source.sendMessage(feature.getLocalizationHandler() + .getMessage("friend.data_unavailable") + .forAudience(source) + .build()); + } + + private void logFailure(String operation, Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Friends] " + operation + " failed: " + message); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public String[] getAliases() { + return delegate.getAliases(); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java index e87318c2..0c89c95d 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanCommand.java @@ -6,6 +6,8 @@ import nl.hauntedmc.proxyfeatures.api.command.FeatureCommand; import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionSuggestionLookup; import java.util.List; import java.util.Map; @@ -19,7 +21,6 @@ public UnbanCommand(Sanctions feature) { this.feature = feature; } - public void execute(Invocation inv) { CommandSource src = inv.source(); String[] a = inv.arguments(); @@ -52,21 +53,13 @@ public void execute(Invocation inv) { } String actorName = (src instanceof Player pl) ? pl.getUsername() : "CONSOLE"; - - // Feedback to executor sendMsg(src, "sanctions.unbanned", Map.of("target", target.getUsername())); - // Broadcast to staff Map ph = Map.of("target", target.getUsername(), "actor", actorName); - feature.getService().broadcastToStaff( - "sanctions.announce.unban", - MessagePlaceholders.of(ph) - ); - + feature.getService().broadcastToStaff("sanctions.announce.unban", MessagePlaceholders.of(ph)); feature.getDiscordService().sendUnban(target, actorName); } - public boolean hasPermission(Invocation inv) { return inv.source().hasPermission("proxyfeatures.feature.sanctions.command.unban"); } @@ -79,16 +72,37 @@ public String[] getAliases() { return new String[0]; } - public CompletableFuture> suggestAsync(Invocation invocation) { - String[] a = invocation.arguments(); - String prefix = (a.length >= 1) ? a[0] : ""; - List names = feature.getService() - .suggestActiveTargetNames(nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType.BAN, prefix, 20); - return CompletableFuture.completedFuture(names); + String[] arguments = invocation.arguments(); + if (arguments.length > 1) { + return CompletableFuture.completedFuture(List.of()); + } + + String prefix = arguments.length == 1 ? arguments[0] : ""; + CompletableFuture> suggestions = new CompletableFuture<>(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.BAN, prefix, 20)); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + return suggestions; + } + + private void logSuggestionFailure(Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Sanctions] Failed to provide /unban suggestions: " + message); } - // helpers private void sendMsg(CommandSource src, String key) { src.sendMessage(feature.getLocalizationHandler().getMessage(key).forAudience(src).build()); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java index ce99a15a..bdcbc605 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnbanIpCommand.java @@ -18,7 +18,6 @@ public UnbanIpCommand(Sanctions feature) { this.feature = feature; } - public void execute(Invocation inv) { CommandSource src = inv.source(); String[] a = inv.arguments(); @@ -49,11 +48,9 @@ public void execute(Invocation inv) { return; } - // Feedback to executor sendMsg(src, "sanctions.unbanned_ip", Map.of("ip", ip)); } - public boolean hasPermission(Invocation inv) { return inv.source().hasPermission("proxyfeatures.feature.sanctions.command.unbanip"); } @@ -66,15 +63,37 @@ public String[] getAliases() { return new String[0]; } - public CompletableFuture> suggestAsync(Invocation invocation) { - String[] a = invocation.arguments(); - String prefix = (a.length >= 1) ? a[0] : ""; - List ips = feature.getService().suggestActiveBannedIps(prefix, 20); - return CompletableFuture.completedFuture(ips); + String[] arguments = invocation.arguments(); + if (arguments.length > 1) { + return CompletableFuture.completedFuture(List.of()); + } + + String prefix = arguments.length == 1 ? arguments[0] : ""; + CompletableFuture> suggestions = new CompletableFuture<>(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(feature.getService().suggestActiveBannedIps(prefix, 20)); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + return suggestions; + } + + private void logSuggestionFailure(Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Sanctions] Failed to provide /unbanip suggestions: " + message); } - // helpers private String normalizeIp(String ip) { try { return InetAddress.getByName(ip).getHostAddress(); diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java index 92dc3691..76b64816 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/command/UnmuteCommand.java @@ -7,6 +7,7 @@ import nl.hauntedmc.proxyfeatures.api.util.text.placeholder.MessagePlaceholders; import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.features.sanctions.service.SanctionSuggestionLookup; import java.util.List; import java.util.Map; @@ -20,7 +21,6 @@ public UnmuteCommand(Sanctions feature) { this.feature = feature; } - public void execute(Invocation inv) { CommandSource src = inv.source(); String[] a = inv.arguments(); @@ -53,19 +53,11 @@ public void execute(Invocation inv) { } String actorName = (src instanceof Player pl) ? pl.getUsername() : "CONSOLE"; - - // Feedback to executor sendMsg(src, "sanctions.unmuted", Map.of("target", target.getUsername())); - - // Announce to staff Map ph = Map.of("target", target.getUsername(), "actor", actorName); - feature.getService().broadcastToStaff( - "sanctions.announce.unmute", - MessagePlaceholders.of(ph) - ); + feature.getService().broadcastToStaff("sanctions.announce.unmute", MessagePlaceholders.of(ph)); - // Notify player if online feature.getPlugin().getProxy().getPlayer(java.util.UUID.fromString(target.getUuid())) .ifPresent(pl -> pl.sendMessage(feature.getLocalizationHandler() .getMessage("sanctions.notify.unmuted") @@ -73,7 +65,6 @@ public void execute(Invocation inv) { feature.getDiscordService().sendUnmute(target, actorName); } - public boolean hasPermission(Invocation inv) { return inv.source().hasPermission("proxyfeatures.feature.sanctions.command.unmute"); } @@ -86,16 +77,37 @@ public String[] getAliases() { return new String[0]; } - public CompletableFuture> suggestAsync(Invocation invocation) { - String[] a = invocation.arguments(); - String prefix = (a.length >= 1) ? a[0] : ""; - List names = feature.getService() - .suggestActiveTargetNames(SanctionType.MUTE, prefix, 20); - return CompletableFuture.completedFuture(names); + String[] arguments = invocation.arguments(); + if (arguments.length > 1) { + return CompletableFuture.completedFuture(List.of()); + } + + String prefix = arguments.length == 1 ? arguments[0] : ""; + CompletableFuture> suggestions = new CompletableFuture<>(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + suggestions.complete(SanctionSuggestionLookup.activeTargetNames(feature, SanctionType.MUTE, prefix, 20)); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + logSuggestionFailure(exception); + suggestions.complete(List.of()); + } + return suggestions; + } + + private void logSuggestionFailure(Throwable failure) { + String message = failure == null || failure.getMessage() == null + ? failure == null ? "unknown" : failure.getClass().getSimpleName() + : failure.getMessage(); + feature.getLogger().error("[Sanctions] Failed to provide /unmute suggestions: " + message); } - // helpers private void sendMsg(CommandSource src, String key) { src.sendMessage(feature.getLocalizationHandler().getMessage(key).forAudience(src).build()); } diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java new file mode 100644 index 00000000..8ae23fb6 --- /dev/null +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/sanctions/service/SanctionSuggestionLookup.java @@ -0,0 +1,56 @@ +package nl.hauntedmc.proxyfeatures.features.sanctions.service; + +import nl.hauntedmc.proxyfeatures.features.sanctions.Sanctions; +import nl.hauntedmc.proxyfeatures.features.sanctions.entity.SanctionType; +import nl.hauntedmc.proxyfeatures.framework.persistence.PlayerReferenceResolver; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * Null-safe, persisted identity lookup for database-backed sanction suggestions. + */ +public final class SanctionSuggestionLookup { + + private SanctionSuggestionLookup() { + } + + public static List activeTargetNames( + Sanctions feature, + SanctionType type, + String startsWith, + int limit + ) { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(type, "type"); + int resultLimit = Math.max(0, limit); + if (resultLimit == 0) { + return List.of(); + } + + String prefix = startsWith == null ? "" : startsWith.toLowerCase(Locale.ROOT); + PlayerReferenceResolver resolver = new PlayerReferenceResolver(feature.getPlugin().getDataRegistry() + .orElseThrow(() -> new IllegalStateException("DataRegistryApi is required for sanction suggestions."))); + + return feature.getOrm().runInTransaction(session -> + session.createQuery( + "select distinct s.targetPlayerId " + + "from SanctionEntity s " + + "where s.active = true and s.type = :type and s.targetPlayerId is not null " + + "order by s.targetPlayerId asc", + Long.class) + .setParameter("type", type) + .list().stream() + .map(resolver::findActiveIdentityById) + .flatMap(java.util.Optional::stream) + .map(identity -> identity.username()) + .filter(Objects::nonNull) + .filter(username -> username.toLowerCase(Locale.ROOT).startsWith(prefix)) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .limit(resultLimit) + .toList() + ); + } +} diff --git a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java index 25cb0abd..e66c9e21 100644 --- a/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java +++ b/proxyfeatures-platform-velocity/src/main/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommand.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.concurrent.CompletableFuture; /** @@ -18,6 +19,8 @@ */ public class TwoFactorCommand implements FeatureCommand { + private static final int MAX_USERNAME_SUGGESTIONS = 50; + private final TwoFactor feature; public TwoFactorCommand(TwoFactor feature) { @@ -82,11 +85,39 @@ public CompletableFuture> suggestAsync(Invocation invocation) { return CompletableFuture.completedFuture(suggestions); } - if (args.length == 2 && "reset".equalsIgnoreCase(args[0]) && (!(source instanceof Player) || source.hasPermission(TwoFactorPermissions.RESET))) { - return CompletableFuture.completedFuture(feature.getService().knownUsernames()); + if (args.length != 2 + || !"reset".equalsIgnoreCase(args[0]) + || (source instanceof Player && !source.hasPermission(TwoFactorPermissions.RESET))) { + return CompletableFuture.completedFuture(List.of()); } - return CompletableFuture.completedFuture(List.of()); + String prefix = args[1] == null ? "" : args[1].toLowerCase(Locale.ROOT); + CompletableFuture> suggestions = new CompletableFuture<>(); + try { + feature.getLifecycleManager().getTaskManager().scheduleTask(() -> { + try { + List usernames = feature.getService().knownUsernames().stream() + .filter(Objects::nonNull) + .filter(username -> username.toLowerCase(Locale.ROOT).startsWith(prefix)) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .limit(MAX_USERNAME_SUGGESTIONS) + .toList(); + suggestions.complete(usernames); + } catch (RuntimeException exception) { + feature.getLogger().warn( + "Could not provide 2FA reset suggestions: " + safeMessage(exception) + ); + suggestions.complete(List.of()); + } + }); + } catch (RuntimeException exception) { + feature.getLogger().warn( + "Could not schedule 2FA reset suggestions: " + safeMessage(exception) + ); + suggestions.complete(List.of()); + } + return suggestions; } @Override @@ -259,4 +290,10 @@ private static String describeSource(CommandSource source) { } return source.getClass().getSimpleName(); } + + private static String safeMessage(Throwable throwable) { + return throwable == null || throwable.getMessage() == null + ? throwable == null ? "unknown" : throwable.getClass().getSimpleName() + : throwable.getMessage(); + } } diff --git a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java index 75790bdf..0ea00101 100644 --- a/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java +++ b/proxyfeatures-platform-velocity/src/test/java/nl/hauntedmc/proxyfeatures/features/twofactor/command/TwoFactorCommandTest.java @@ -8,7 +8,10 @@ import nl.hauntedmc.proxyfeatures.features.twofactor.audit.TwoFactorAuditLogService; import nl.hauntedmc.proxyfeatures.features.twofactor.policy.TwoFactorPermissions; import nl.hauntedmc.proxyfeatures.features.twofactor.service.TwoFactorService; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureLifecycleManager; +import nl.hauntedmc.proxyfeatures.framework.lifecycle.FeatureTaskManager; import nl.hauntedmc.proxyfeatures.framework.localization.LocalizationHandler; +import nl.hauntedmc.proxyfeatures.framework.log.FeatureLogger; import org.junit.jupiter.api.Test; import java.util.List; @@ -36,17 +39,41 @@ void suggestAsyncOnlyReturnsSubcommandsAllowedForPlayer() { } @Test - void suggestAsyncIncludesResetForAuthorizedSources() { + void resetSuggestionsRunThroughFeatureTaskManagerAndFilterPrefix() { TwoFactor feature = mock(TwoFactor.class); TwoFactorService service = mock(TwoFactorService.class); + FeatureLifecycleManager lifecycle = mock(FeatureLifecycleManager.class); + FeatureTaskManager tasks = mock(FeatureTaskManager.class); + FeatureLogger logger = mock(FeatureLogger.class); when(feature.getAuditLogService()).thenReturn(mock(TwoFactorAuditLogService.class)); when(feature.getService()).thenReturn(service); - when(service.knownUsernames()).thenReturn(List.of("Remy")); + when(feature.getLifecycleManager()).thenReturn(lifecycle); + when(feature.getLogger()).thenReturn(logger); + when(lifecycle.getTaskManager()).thenReturn(tasks); + when(tasks.scheduleTask(any())).thenAnswer(invocation -> { + Runnable task = invocation.getArgument(0); + task.run(); + return null; + }); + when(service.knownUsernames()).thenReturn(List.of("Remy", "Rens", "Alice", "remy")); TwoFactorCommand command = new TwoFactorCommand(feature); assertEquals(List.of("setup", "reset"), command.suggestAsync(invocation(console(), "")).join()); - assertEquals(List.of("Remy"), command.suggestAsync(invocation(console(), "reset", "R")).join()); + assertEquals(List.of("Remy", "remy", "Rens"), command.suggestAsync(invocation(console(), "reset", "R")).join()); + verify(tasks).scheduleTask(any()); + } + + @Test + void resetSuggestionsDoNotQueryAfterFinalArgument() { + TwoFactor feature = mock(TwoFactor.class); + TwoFactorService service = mock(TwoFactorService.class); + when(feature.getService()).thenReturn(service); + + TwoFactorCommand command = new TwoFactorCommand(feature); + + assertEquals(List.of(), command.suggestAsync(invocation(console(), "reset", "Remy", "extra")).join()); + verifyNoInteractions(service); } @Test