feat(quests): add issuer quest cancellation
This commit is contained in:
@@ -52,6 +52,41 @@ final class QuestBoardDialogUiTest {
|
||||
assertEquals(NOW, completer.completedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationActionUsesEquivalentCancellationFlow() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
RecordingCanceller canceller = new RecordingCanceller();
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
ui.submitCancellation(player, id);
|
||||
|
||||
assertEquals(player, canceller.player);
|
||||
assertEquals(id, canceller.questId);
|
||||
assertEquals(NOW, canceller.cancelledAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Quest owned = creator.quest(NOW);
|
||||
Quest other = new Quest(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "Other", "DIRT", 1,
|
||||
List.of(new EscrowItem("COAL", 1, null)), NOW, NOW.plusSeconds(604800)
|
||||
);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(owned, other), null, new RecordingCanceller(),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player issuer = mock(Player.class);
|
||||
org.mockito.Mockito.when(issuer.getUniqueId()).thenReturn(owned.issuerId());
|
||||
|
||||
assertEquals(List.of(owned.id().toString()), ui.cancellableQuestIds(issuer));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyOpeningBuildsAListingFromTheCurrentActiveQuests() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
@@ -77,6 +112,20 @@ final class QuestBoardDialogUiTest {
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingCanceller implements QuestCancellationGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
private Instant cancelledAt;
|
||||
|
||||
@Override
|
||||
public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) {
|
||||
player = cancellingPlayer;
|
||||
questId = id;
|
||||
cancelledAt = instant;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCompleter implements QuestCompletionGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCancellationControllerTest {
|
||||
@Test
|
||||
void cancelsAsPlayerIdentityAndCreatesClaimWithoutInventorySettlement() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest quest = service.create(
|
||||
issuer, "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 2, "exact-data")), Instant.EPOCH
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(issuer);
|
||||
|
||||
QuestClaim claim = new QuestCancellationController(service).cancel(
|
||||
player, quest.id().toString(), Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
|
||||
assertEquals(quest.reward(), claim.items());
|
||||
assertEquals(List.of(claim), service.state().claims().get(issuer));
|
||||
verify(player).sendMessage(contains("claim"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedQuestIdentifier() throws Exception {
|
||||
QuestCancellationController controller = new QuestCancellationController(
|
||||
new QuestService(new Repository())
|
||||
);
|
||||
|
||||
IllegalArgumentException exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.cancel(mock(Player.class), "not-a-uuid", Instant.EPOCH)
|
||||
);
|
||||
|
||||
assertEquals("Quest identifier must be a valid UUID.", exception.getMessage());
|
||||
}
|
||||
|
||||
private static final class Repository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override public void save(QuestState candidate) throws IOException { state = candidate; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCancellationServiceTest {
|
||||
@Test
|
||||
void onlyIssuerCanCancelAndExactRewardBecomesClaim() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-item-data");
|
||||
Quest quest = service.create(
|
||||
issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH
|
||||
);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> service.cancel(
|
||||
quest.id(), UUID.randomUUID(), Instant.EPOCH.plusSeconds(1)
|
||||
));
|
||||
QuestClaim claim = service.cancel(
|
||||
quest.id(), issuer, Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
|
||||
assertEquals(QuestStatus.CANCELLED, service.state().quests().get(quest.id()).status());
|
||||
assertEquals(List.of(reward), claim.items());
|
||||
assertEquals(List.of(claim), service.state().claims().get(issuer));
|
||||
assertTrue(service.activeQuests(Instant.EPOCH.plusSeconds(1)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedCancelledAndExpiredQuestsCannotBeCancelled() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest completed = quest(service, issuer);
|
||||
service.complete(
|
||||
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
Quest cancelled = quest(service, issuer);
|
||||
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
|
||||
Quest expired = quest(service, issuer);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.cancel(
|
||||
completed.id(), issuer, Instant.EPOCH.plusSeconds(2)
|
||||
));
|
||||
assertThrows(IllegalStateException.class, () -> service.cancel(
|
||||
cancelled.id(), issuer, Instant.EPOCH.plusSeconds(2)
|
||||
));
|
||||
assertThrows(IllegalStateException.class, () -> service.cancel(
|
||||
expired.id(), issuer, expired.expiresAt()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureLeavesQuestActiveAndRewardEscrowed() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest quest = quest(service, issuer);
|
||||
QuestState before = service.state();
|
||||
repository.fail = true;
|
||||
|
||||
assertThrows(IOException.class, () -> service.cancel(
|
||||
quest.id(), issuer, Instant.EPOCH.plusSeconds(1)
|
||||
));
|
||||
|
||||
assertEquals(before, service.state());
|
||||
assertEquals(before, repository.state);
|
||||
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
|
||||
assertTrue(service.state().claims().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void simultaneousCancellationAndCompletionSettleEscrowExactlyOnce() throws Exception {
|
||||
Repository repository = new Repository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
Quest quest = quest(service, issuer);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
List<Future<Boolean>> attempts = new ArrayList<>();
|
||||
attempts.add(executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
service.cancel(quest.id(), issuer, Instant.EPOCH.plusSeconds(1));
|
||||
return true;
|
||||
} catch (IllegalStateException exception) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
attempts.add(executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
service.complete(
|
||||
quest.id(), new EscrowItem("STONE", 1, null),
|
||||
Instant.EPOCH.plusSeconds(1)
|
||||
);
|
||||
return true;
|
||||
} catch (IllegalStateException exception) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
start.countDown();
|
||||
assertEquals(1, attempts.stream().filter(attempt -> {
|
||||
try {
|
||||
return attempt.get();
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
}).count());
|
||||
}
|
||||
|
||||
QuestStatus status = service.state().quests().get(quest.id()).status();
|
||||
assertTrue(status == QuestStatus.CANCELLED || status == QuestStatus.COMPLETED);
|
||||
assertEquals(1, service.state().claims().get(issuer).size());
|
||||
QuestClaim settlement = service.state().claims().get(issuer).getFirst();
|
||||
if (status == QuestStatus.CANCELLED) {
|
||||
assertEquals(quest.reward(), settlement.items());
|
||||
assertTrue(service.state().notifications().isEmpty());
|
||||
} else {
|
||||
assertEquals(List.of(new EscrowItem("STONE", 1, null)), settlement.items());
|
||||
assertEquals(1, service.state().notifications().size());
|
||||
}
|
||||
}
|
||||
|
||||
private static Quest quest(QuestService service, UUID issuer) throws IOException {
|
||||
return service.create(
|
||||
issuer, "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 2, null)), Instant.EPOCH
|
||||
);
|
||||
}
|
||||
|
||||
private static final class Repository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private boolean fail;
|
||||
|
||||
@Override public QuestState load() { return state; }
|
||||
|
||||
@Override
|
||||
public void save(QuestState candidate) throws IOException {
|
||||
if (fail) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
state = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,25 @@ final class QuestCommandTest {
|
||||
assertEquals(NOW, completer.completedAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
RecordingCanceller canceller = new RecordingCanceller();
|
||||
QuestCommand executor = new QuestCommand(
|
||||
creator, now -> List.of(), null, canceller, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"cancel", id}
|
||||
));
|
||||
|
||||
assertEquals(player, canceller.player);
|
||||
assertEquals(id, canceller.questId);
|
||||
assertEquals(NOW, canceller.cancelledAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
@@ -139,6 +158,8 @@ final class QuestCommandTest {
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"cr"}));
|
||||
assertEquals(List.of("list"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"li"}));
|
||||
assertEquals(List.of("cancel"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"ca"}));
|
||||
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "sto"}
|
||||
));
|
||||
@@ -159,6 +180,20 @@ final class QuestCommandTest {
|
||||
return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static final class RecordingCanceller implements QuestCancellationGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
private Instant cancelledAt;
|
||||
|
||||
@Override
|
||||
public QuestClaim cancel(Player cancellingPlayer, String id, Instant instant) {
|
||||
player = cancellingPlayer;
|
||||
questId = id;
|
||||
cancelledAt = instant;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingCompleter implements QuestCompletionGateway {
|
||||
private Player player;
|
||||
private String questId;
|
||||
|
||||
Reference in New Issue
Block a user