feat(admin): control player quest commands
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m15s

This commit is contained in:
dmg
2026-09-05 08:36:45 -04:00
parent af6d6d0827
commit be13b9b87c
14 changed files with 537 additions and 30 deletions
@@ -0,0 +1,52 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class PlayerCommandSettingsTest {
@TempDir Path temporaryDirectory;
@Test
void defaultsDisabledAndPersistsEnabledState() throws Exception {
Path path = temporaryDirectory.resolve("settings.yml");
PlayerCommandSettings settings = new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(path)
);
assertFalse(settings.enabled());
settings.setEnabled(true);
PlayerCommandSettings reloaded = new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(path)
);
assertTrue(reloaded.enabled());
reloaded.setEnabled(false);
assertFalse(new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(path)
).enabled());
}
@Test
void persistenceFailureLeavesPriorState() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) throws IOException {
throw new IOException("disk full");
}
}
);
assertThrows(IOException.class, () -> settings.setEnabled(true));
assertFalse(settings.enabled());
}
}
@@ -0,0 +1,56 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.util.List;
import java.util.Set;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class PluginCommandWiringTest {
@Test
void adminAndPlayerCommandsShareThePersistedSetting() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
private boolean enabled;
@Override public boolean loadEnabled() { return enabled; }
@Override public void saveEnabled(boolean enabled) { this.enabled = enabled; }
}
);
BoardRepository boards = new BoardRepository() {
@Override public BoardState load() { return new BoardState(Set.of()); }
@Override public void save(BoardState state) { }
};
QuestBrowser browser = mock(QuestBrowser.class);
when(browser.activeQuests(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
SpigotQuestBoardPlugin.CommandHandlers handlers =
SpigotQuestBoardPlugin.commandHandlers(
new BoardRegistry(boards), settings, mock(QuestCreationGateway.class), browser,
null, null, null, Clock.systemUTC()
);
Player player = mock(Player.class);
CommandSender admin = mock(CommandSender.class);
when(admin.hasPermission("spigotquestboard.admin")).thenReturn(true);
Command command = mock(Command.class);
assertTrue(handlers.quests().onCommand(
player, command, "quests", new String[] {"list"}
));
verify(player).sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
handlers.admin().onCommand(
admin, command, "questadmin", new String[] {"commands", "enable"}
);
handlers.quests().onCommand(player, command, "quests", new String[] {"list"});
verify(browser).activeQuests(org.mockito.ArgumentMatchers.any());
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@@ -7,6 +8,8 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.bukkit.World;
@@ -20,7 +23,10 @@ final class QuestAdminCommandTest {
@Test
void unauthorizedSenderCannotCreateBoard() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(repository),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
Player player = mock(Player.class);
when(player.hasPermission("spigotquestboard.admin")).thenReturn(false);
@@ -30,10 +36,111 @@ final class QuestAdminCommandTest {
assertTrue(repository.state.boards().isEmpty());
}
@Test
void authorizedSenderCanEnableAndDisablePlayerCommands() throws Exception {
MemoryPlayerCommandSettingsRepository settingsRepository =
new MemoryPlayerCommandSettingsRepository();
PlayerCommandSettings settings = new PlayerCommandSettings(settingsRepository);
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()), settings
);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true);
assertTrue(executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
));
assertTrue(settings.enabled());
assertTrue(executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "disable"}
));
assertFalse(settings.enabled());
verify(sender).sendMessage("Player quest commands enabled.");
verify(sender).sendMessage("Player quest commands disabled.");
}
@Test
void unauthorizedSenderCannotChangePlayerCommands() throws Exception {
MemoryPlayerCommandSettingsRepository repository =
new MemoryPlayerCommandSettingsRepository();
PlayerCommandSettings settings = new PlayerCommandSettings(repository);
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()), settings
);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(false);
executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
);
assertFalse(settings.enabled());
assertEquals(0, repository.saves);
}
@Test
void settingsPersistenceFailureIsReportedAndLeavesPriorState() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) throws IOException {
throw new IOException("disk full");
}
}
);
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()), settings
);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true);
executor.onCommand(
sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"}
);
assertFalse(settings.enabled());
verify(sender).sendMessage(
"Player command setting could not be saved. Player quest commands remain disabled."
);
}
@Test
void autocompleteIsPermissionAwareAndContextual() throws Exception {
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(new MemoryBoardRepository()),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
CommandSender authorized = mock(CommandSender.class);
when(authorized.hasPermission("spigotquestboard.admin")).thenReturn(true);
CommandSender unauthorized = mock(CommandSender.class);
when(unauthorized.hasPermission("spigotquestboard.admin")).thenReturn(false);
Command command = mock(Command.class);
assertEquals(List.of("commands"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"com"}
));
assertEquals(List.of("enable"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"commands", "en"}
));
assertEquals(List.of("disable"), executor.onTabComplete(
authorized, command, "questadmin", new String[] {"commands", "di"}
));
assertTrue(executor.onTabComplete(
authorized, command, "questadmin", new String[] {"createboard", ""}
).isEmpty());
assertTrue(executor.onTabComplete(
unauthorized, command, "questadmin", new String[] {""}
).isEmpty());
}
@Test
void registersTargetedPhysicalBlock() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(repository),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
Player player = mock(Player.class);
Block block = mock(Block.class);
World world = mock(World.class);
@@ -57,7 +164,10 @@ final class QuestAdminCommandTest {
@Test
void consoleAndMissingTargetDoNotChangeState() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository();
QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository));
QuestAdminCommand executor = new QuestAdminCommand(
new BoardRegistry(repository),
new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository())
);
CommandSender console = mock(CommandSender.class);
when(console.hasPermission("spigotquestboard.admin")).thenReturn(true);
assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"}));
@@ -70,6 +180,18 @@ final class QuestAdminCommandTest {
assertTrue(repository.state.boards().isEmpty());
}
private static final class MemoryPlayerCommandSettingsRepository
implements PlayerCommandSettingsRepository {
private boolean enabled;
private int saves;
@Override public boolean loadEnabled() { return enabled; }
@Override public void saveEnabled(boolean enabled) {
this.enabled = enabled;
saves++;
}
}
private static final class MemoryBoardRepository implements BoardRepository {
private BoardState state = new BoardState(Set.of());
@Override public BoardState load() { return state; }
@@ -16,6 +16,42 @@ import org.junit.jupiter.api.Test;
final class QuestBoardDialogUiTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
@Test
void disabledPlayerCommandsDoNotGateBoardUiGateways() throws Exception {
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) { }
}
);
RecordingCreator creator = new RecordingCreator(false);
RecordingCompleter completer = new RecordingCompleter();
RecordingCanceller canceller = new RecordingCanceller();
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
new java.util.concurrent.atomic.AtomicReference<>();
QuestClaimGateway claimant = player -> {
claimantPlayer.set(player);
return new ClaimCollectionResult(0, 0);
};
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(creator.quest(NOW)), completer, canceller, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
ui.listingText();
ui.submit(player, "stone", "1");
ui.submitCompletion(player, "complete-id");
ui.submitCancellation(player, "cancel-id");
ui.submitClaim(player);
org.junit.jupiter.api.Assertions.assertFalse(settings.enabled());
assertEquals("stone", creator.material);
assertEquals(player, completer.player);
assertEquals(player, canceller.player);
assertEquals(player, claimantPlayer.get());
}
@Test
void dialogSubmissionUsesEquivalentCreationFlow() {
RecordingCreator creator = new RecordingCreator(false);
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
@@ -20,6 +21,47 @@ import org.junit.jupiter.api.Test;
final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
@Test
void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception {
QuestCreationGateway creator = mock(QuestCreationGateway.class);
QuestBrowser browser = mock(QuestBrowser.class);
QuestCompletionGateway completer = mock(QuestCompletionGateway.class);
QuestCancellationGateway canceller = mock(QuestCancellationGateway.class);
QuestClaimGateway claimant = mock(QuestClaimGateway.class);
PlayerCommandSettings settings = new PlayerCommandSettings(
new PlayerCommandSettingsRepository() {
@Override public boolean loadEnabled() { return false; }
@Override public void saveEnabled(boolean enabled) { }
}
);
QuestCommand executor = new QuestCommand(
creator, browser, completer, canceller, claimant,
Clock.fixed(NOW, ZoneOffset.UTC), settings
);
Player player = mock(Player.class);
Command command = mock(Command.class);
List<String[]> forms = List.of(
new String[] {},
new String[] {"list"},
new String[] {"create", "stone", "1"},
new String[] {"complete", "quest-id"},
new String[] {"cancel", "quest-id"},
new String[] {"claim"}
);
for (String[] form : forms) {
assertTrue(executor.onCommand(player, command, "quests", form));
}
verify(player, times(forms.size())).sendMessage(
"Player quest commands are disabled. Use a physical quest board instead."
);
assertTrue(executor.onTabComplete(
player, command, "quests", new String[] {""}
).isEmpty());
verifyNoInteractions(creator, browser, completer, canceller, claimant);
}
@Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator();