From 0f0fb6f263564275f32c76fc40afb48102cc39c9 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:01:40 +0200 Subject: [PATCH 01/29] feat: add COLLECTED parcel status and guard ParcelSendTask against re-delivery Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../eternalcode/parcellockers/parcel/ParcelStatus.java | 3 ++- .../parcellockers/parcel/task/ParcelSendTask.java | 2 +- .../parcellockers/parcel/task/ParcelSendTaskTest.java | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java b/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java index 8744073be..6f8fb1e3f 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java @@ -3,5 +3,6 @@ public enum ParcelStatus { SENT, - DELIVERED + DELIVERED, + COLLECTED } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index 49c4eaee0..8c93901ce 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -35,7 +35,7 @@ public ParcelSendTask(Parcel parcel, ParcelService parcelService, DeliveryManage /** Pure decision: what to do given the latest parcel + delivery state at fire time. */ public static Decision decide(Optional currentParcel, Optional currentDelivery, Instant now) { - if (currentParcel.isEmpty() || currentParcel.get().status() == ParcelStatus.DELIVERED) { + if (currentParcel.isEmpty() || currentParcel.get().status() != ParcelStatus.SENT) { return Decision.ABORT; } if (currentDelivery.isPresent() && currentDelivery.get().deliveryTimestamp().isAfter(now)) { diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java index 80dace4d4..29014a276 100644 --- a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java +++ b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java @@ -54,4 +54,14 @@ void deliversWhenSentWithNoDeliveryRow() { Parcel sent = parcel(ParcelStatus.SENT); assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.empty(), now)); } + + @Test + void abortsWhenAlreadyCollected() { + // A COLLECTED parcel sits in the receiver's return window; a stale delivery row + // must not re-deliver it, or the items could be collected twice. + Parcel collected = parcel(ParcelStatus.COLLECTED); + Delivery due = new Delivery(collected.uuid(), Instant.parse("2026-06-21T11:00:00Z")); + assertEquals(Decision.ABORT, ParcelSendTask.decide( + Optional.of(collected), Optional.of(due), Instant.parse("2026-06-21T12:00:00Z"))); + } } From 2638dec534215e3f6c35a55920c970089ae86d34 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:07:13 +0200 Subject: [PATCH 02/29] feat: add CollectedParcel domain and collected_parcels repository Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../returns/CollectedParcel.java | 7 ++ .../repository/CollectedParcelRepository.java | 20 ++++ .../CollectedParcelRepositoryOrmLite.java | 52 +++++++++ .../repository/CollectedParcelTable.java | 36 +++++++ ...lectedParcelRepositoryIntegrationTest.java | 101 ++++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java create mode 100644 src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java b/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java new file mode 100644 index 000000000..23fe45674 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java @@ -0,0 +1,7 @@ +package com.eternalcode.parcellockers.returns; + +import java.time.Instant; +import java.util.UUID; + +public record CollectedParcel(UUID parcel, Instant collectedAt) { +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java new file mode 100644 index 000000000..fd2064f40 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java @@ -0,0 +1,20 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.parcellockers.returns.CollectedParcel; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface CollectedParcelRepository { + + CompletableFuture save(CollectedParcel collectedParcel); + + CompletableFuture> find(UUID parcel); + + /** Returns rows collected at or before the given cutoff (i.e. whose return window expired). */ + CompletableFuture> findExpired(Instant cutoff); + + CompletableFuture delete(UUID parcel); +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java new file mode 100644 index 000000000..6de2a0a33 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java @@ -0,0 +1,52 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.database.DatabaseManager; +import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class CollectedParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implements CollectedParcelRepository { + + public CollectedParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { + super(databaseManager, scheduler); + this.createTable(CollectedParcelTable.class); + } + + @Override + public CompletableFuture save(CollectedParcel collectedParcel) { + Objects.requireNonNull(collectedParcel, "CollectedParcel cannot be null"); + return this.insertIfAbsent(CollectedParcelTable.class, CollectedParcelTable.from(collectedParcel)) + .thenApply(dao -> null); + } + + @Override + public CompletableFuture> find(UUID parcel) { + Objects.requireNonNull(parcel, "Parcel UUID cannot be null"); + return this.selectSafe(CollectedParcelTable.class, parcel) + .thenApply(optional -> optional.map(CollectedParcelTable::toCollectedParcel)); + } + + @Override + public CompletableFuture> findExpired(Instant cutoff) { + Objects.requireNonNull(cutoff, "Cutoff cannot be null"); + return this.action(CollectedParcelTable.class, dao -> dao.queryBuilder() + .where() + .le(CollectedParcelTable.COLLECTED_AT_COLUMN, cutoff) + .query() + .stream() + .map(CollectedParcelTable::toCollectedParcel) + .toList()); + } + + @Override + public CompletableFuture delete(UUID parcel) { + Objects.requireNonNull(parcel, "Parcel UUID cannot be null"); + return this.deleteById(CollectedParcelTable.class, parcel).thenApply(rows -> rows > 0); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java new file mode 100644 index 000000000..88c167fe2 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java @@ -0,0 +1,36 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.parcellockers.database.persister.InstantPersister; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.time.Instant; +import java.util.UUID; + +@DatabaseTable(tableName = "collected_parcels") +class CollectedParcelTable { + + static final String COLLECTED_AT_COLUMN = "collected_at"; + + @DatabaseField(id = true) + private UUID parcel; + + @DatabaseField(columnName = COLLECTED_AT_COLUMN, persisterClass = InstantPersister.class) + private Instant collectedAt; + + CollectedParcelTable() { + } + + CollectedParcelTable(UUID parcel, Instant collectedAt) { + this.parcel = parcel; + this.collectedAt = collectedAt; + } + + static CollectedParcelTable from(CollectedParcel collectedParcel) { + return new CollectedParcelTable(collectedParcel.parcel(), collectedParcel.collectedAt()); + } + + CollectedParcel toCollectedParcel() { + return new CollectedParcel(this.parcel, this.collectedAt); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java new file mode 100644 index 000000000..fd11a4cc5 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java @@ -0,0 +1,101 @@ +package com.eternalcode.parcellockers.database; + +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 com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import java.nio.file.Path; +import java.sql.SQLException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers(disabledWithoutDocker = true) +class CollectedParcelRepositoryIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + private CollectedParcelRepository repository() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.MYSQL; + config.settings.host = mySQLContainer.getHost(); + config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); + config.settings.databaseName = mySQLContainer.getDatabaseName(); + config.settings.user = mySQLContainer.getUsername(); + config.settings.password = mySQLContainer.getPassword(); + + DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); + databaseManager.connect(); + this.databaseManager = databaseManager; + + return new CollectedParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + } + + @Test + void savesAndFindsCollectedParcel() throws SQLException { + CollectedParcelRepository repository = this.repository(); + UUID parcel = UUID.randomUUID(); + // MySQL TIMESTAMP columns don't keep nanos; truncate so the round-trip compares equal. + Instant collectedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + this.await(repository.save(new CollectedParcel(parcel, collectedAt))); + + Optional found = this.await(repository.find(parcel)); + assertTrue(found.isPresent()); + assertEquals(collectedAt, found.get().collectedAt()); + } + + @Test + void findExpiredReturnsOnlyRowsAtOrBeforeCutoff() throws SQLException { + CollectedParcelRepository repository = this.repository(); + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + UUID expired = UUID.randomUUID(); + UUID fresh = UUID.randomUUID(); + this.await(repository.save(new CollectedParcel(expired, now.minusSeconds(3600)))); + this.await(repository.save(new CollectedParcel(fresh, now))); + + List result = this.await(repository.findExpired(now.minusSeconds(60))); + assertEquals(1, result.size()); + assertEquals(expired, result.get(0).parcel()); + } + + @Test + void deleteRemovesRow() throws SQLException { + CollectedParcelRepository repository = this.repository(); + UUID parcel = UUID.randomUUID(); + this.await(repository.save(new CollectedParcel(parcel, Instant.now().truncatedTo(ChronoUnit.SECONDS)))); + + assertTrue(this.await(repository.delete(parcel))); + assertFalse(this.await(repository.find(parcel)).isPresent()); + assertFalse(this.await(repository.delete(parcel))); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} From f8340692d2bc9894832f7755e3f3068d03140e71 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:14:44 +0200 Subject: [PATCH 03/29] fix: compare return-window expiry temporally, not lexicographically The collected_at column is a string-persisted ISO-8601 Instant; SQL range operators compare it lexicographically and misorder same-second values. Filter in Java and cover the cutoff boundary in the integration test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../CollectedParcelRepositoryOrmLite.java | 16 +++++++++------- ...CollectedParcelRepositoryIntegrationTest.java | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java index 6de2a0a33..e27986fad 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java @@ -35,13 +35,15 @@ public CompletableFuture> find(UUID parcel) { @Override public CompletableFuture> findExpired(Instant cutoff) { Objects.requireNonNull(cutoff, "Cutoff cannot be null"); - return this.action(CollectedParcelTable.class, dao -> dao.queryBuilder() - .where() - .le(CollectedParcelTable.COLLECTED_AT_COLUMN, cutoff) - .query() - .stream() - .map(CollectedParcelTable::toCollectedParcel) - .toList()); + // collected_at is persisted as an ISO-8601 string (InstantPersister); a SQL range operator + // would compare it lexicographically, which misorders same-second values with different + // fractional renderings. Compare temporally in Java instead — the table only holds parcels + // inside the return window, so a full scan per purge run is cheap. + return this.selectAll(CollectedParcelTable.class) + .thenApply(rows -> rows.stream() + .map(CollectedParcelTable::toCollectedParcel) + .filter(collected -> !collected.collectedAt().isAfter(cutoff)) + .toList()); } @Override diff --git a/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java index fd11a4cc5..ef28c000b 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java +++ b/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java @@ -70,15 +70,24 @@ void savesAndFindsCollectedParcel() throws SQLException { void findExpiredReturnsOnlyRowsAtOrBeforeCutoff() throws SQLException { CollectedParcelRepository repository = this.repository(); Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant cutoff = now.minusSeconds(60); UUID expired = UUID.randomUUID(); + UUID atCutoff = UUID.randomUUID(); + UUID justAfterCutoff = UUID.randomUUID(); UUID fresh = UUID.randomUUID(); this.await(repository.save(new CollectedParcel(expired, now.minusSeconds(3600)))); + this.await(repository.save(new CollectedParcel(atCutoff, cutoff))); + // Sub-second offset: guards the temporal (not lexicographic) comparison of the + // string-persisted column. + this.await(repository.save(new CollectedParcel(justAfterCutoff, cutoff.plusMillis(500)))); this.await(repository.save(new CollectedParcel(fresh, now))); - List result = this.await(repository.findExpired(now.minusSeconds(60))); - assertEquals(1, result.size()); - assertEquals(expired, result.get(0).parcel()); + List result = this.await(repository.findExpired(cutoff)); + List resultParcels = result.stream().map(CollectedParcel::parcel).toList(); + assertEquals(2, result.size()); + assertTrue(resultParcels.contains(expired)); + assertTrue(resultParcels.contains(atCutoff)); } @Test From ef1af91bb962b5c52bc24495e95337d2069017b6 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:22:24 +0200 Subject: [PATCH 04/29] feat: add conditional collect/return status flips and returnable query to ParcelRepository Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../parcel/repository/ParcelRepository.java | 16 +++ .../repository/ParcelRepositoryOrmLite.java | 50 +++++++ ...ParcelReturnRepositoryIntegrationTest.java | 134 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java index 43f5b49f0..1719c7688 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -43,6 +43,22 @@ public interface ParcelRepository { */ CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page); + /** + * Atomically flips a DELIVERED parcel to COLLECTED. Returns false when the parcel is missing + * or not DELIVERED — the caller must treat that as "someone else already collected it". + */ + CompletableFuture markCollected(UUID uuid); + + /** + * Atomically turns a COLLECTED parcel into its reverse SENT shipment (parties and lockers + * swapped as prepared by the caller). Returns false when the parcel is missing or not + * COLLECTED — the caller must treat that as "already returned or purged". + */ + CompletableFuture markReturned(Parcel returned); + + /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ + CompletableFuture> findReturnable(UUID receiver, Page page); + /** * Counts the parcels currently occupying a destination locker. Collected parcels are removed * from storage, so every parcel addressed to the locker (in-transit or delivered) occupies a diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java index 43c560084..adec6b8c4 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -7,6 +7,7 @@ import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; +import com.j256.ormlite.stmt.UpdateBuilder; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -15,9 +16,11 @@ public class ParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implements ParcelRepository { + private static final String UUID_COLUMN = "uuid"; private static final String RECEIVER_COLUMN = "receiver"; private static final String SENDER_COLUMN = "sender"; private static final String DESTINATION_LOCKER_COLUMN = "destination_locker"; + private static final String ENTRY_LOCKER_COLUMN = "entry_locker"; private static final String STATUS_COLUMN = "status"; public ParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { @@ -96,6 +99,51 @@ public CompletableFuture> findCollectible(UUID receiver, UUID }, ParcelTable::toParcel); } + @Override + public CompletableFuture markCollected(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + return this.action(ParcelTable.class, dao -> { + UpdateBuilder builder = dao.updateBuilder(); + builder.updateColumnValue(STATUS_COLUMN, ParcelStatus.COLLECTED); + builder.where() + .eq(UUID_COLUMN, uuid) + .and() + .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); + return builder.update() > 0; + }); + } + + @Override + public CompletableFuture markReturned(Parcel returned) { + Objects.requireNonNull(returned, "Returned parcel cannot be null"); + return this.action(ParcelTable.class, dao -> { + UpdateBuilder builder = dao.updateBuilder(); + builder.updateColumnValue(SENDER_COLUMN, returned.sender()); + builder.updateColumnValue(RECEIVER_COLUMN, returned.receiver()); + builder.updateColumnValue(ENTRY_LOCKER_COLUMN, returned.entryLocker()); + builder.updateColumnValue(DESTINATION_LOCKER_COLUMN, returned.destinationLocker()); + builder.updateColumnValue(STATUS_COLUMN, ParcelStatus.SENT); + builder.where() + .eq(UUID_COLUMN, returned.uuid()) + .and() + .eq(STATUS_COLUMN, ParcelStatus.COLLECTED); + return builder.update() > 0; + }); + } + + @Override + public CompletableFuture> findReturnable(UUID receiver, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + return this.queryPage(ParcelTable.class, page, builder -> { + builder.where() + .eq(RECEIVER_COLUMN, receiver) + .and() + .eq(STATUS_COLUMN, ParcelStatus.COLLECTED); + return builder; + }, ParcelTable::toParcel); + } + @Override public CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker) { Objects.requireNonNull(destinationLocker, "Destination locker UUID cannot be null"); @@ -103,6 +151,8 @@ public CompletableFuture countParcelsByDestinationLocker(UUID destinati long count = dao.queryBuilder() .where() .eq(DESTINATION_LOCKER_COLUMN, destinationLocker) + .and() + .ne(STATUS_COLUMN, ParcelStatus.COLLECTED) .countOf(); return (int) count; }); diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java new file mode 100644 index 000000000..debf5b1a6 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java @@ -0,0 +1,134 @@ +package com.eternalcode.parcellockers.database; + +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 com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers(disabledWithoutDocker = true) +class ParcelReturnRepositoryIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + private ParcelRepository repository() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.MYSQL; + config.settings.host = mySQLContainer.getHost(); + config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); + config.settings.databaseName = mySQLContainer.getDatabaseName(); + config.settings.user = mySQLContainer.getUsername(); + config.settings.password = mySQLContainer.getPassword(); + + DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); + databaseManager.connect(); + this.databaseManager = databaseManager; + + return new ParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + } + + private static Parcel parcel(UUID receiver, UUID destinationLocker, ParcelStatus status) { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "p", "d", false, + receiver, ParcelSize.SMALL, UUID.randomUUID(), destinationLocker, status); + } + + @Test + void markCollectedFlipsOnlyDeliveredParcels() throws SQLException { + ParcelRepository repository = this.repository(); + Parcel delivered = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.DELIVERED); + this.await(repository.save(delivered)); + + assertTrue(this.await(repository.markCollected(delivered.uuid()))); + assertEquals(ParcelStatus.COLLECTED, this.await(repository.findById(delivered.uuid())).orElseThrow().status()); + + // Second collect attempt must not report success — this is the double-collect guard. + assertFalse(this.await(repository.markCollected(delivered.uuid()))); + + Parcel sent = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.SENT); + this.await(repository.save(sent)); + assertFalse(this.await(repository.markCollected(sent.uuid()))); + } + + @Test + void markReturnedSwapsPartiesAndLockersOnlyWhenCollected() throws SQLException { + ParcelRepository repository = this.repository(); + Parcel collected = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED); + this.await(repository.save(collected)); + + Parcel returned = new Parcel(collected.uuid(), collected.receiver(), collected.name(), + collected.description(), collected.priority(), collected.sender(), collected.size(), + collected.destinationLocker(), collected.entryLocker(), ParcelStatus.SENT); + + assertTrue(this.await(repository.markReturned(returned))); + + Parcel stored = this.await(repository.findById(collected.uuid())).orElseThrow(); + assertEquals(collected.receiver(), stored.sender()); + assertEquals(collected.sender(), stored.receiver()); + assertEquals(collected.destinationLocker(), stored.entryLocker()); + assertEquals(collected.entryLocker(), stored.destinationLocker()); + assertEquals(ParcelStatus.SENT, stored.status()); + + // A second return of the same parcel must fail (status is no longer COLLECTED). + assertFalse(this.await(repository.markReturned(returned))); + } + + @Test + void findReturnableReturnsOnlyCollectedParcelsOfReceiver() throws SQLException { + ParcelRepository repository = this.repository(); + UUID receiver = UUID.randomUUID(); + + this.await(repository.save(parcel(receiver, UUID.randomUUID(), ParcelStatus.COLLECTED))); + this.await(repository.save(parcel(receiver, UUID.randomUUID(), ParcelStatus.COLLECTED))); + this.await(repository.save(parcel(receiver, UUID.randomUUID(), ParcelStatus.DELIVERED))); + this.await(repository.save(parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED))); + + PageResult page = this.await(repository.findReturnable(receiver, new Page(0, 10))); + assertEquals(2, page.items().size()); + assertTrue(page.items().stream().allMatch(item -> item.status() == ParcelStatus.COLLECTED)); + assertTrue(page.items().stream().allMatch(item -> item.receiver().equals(receiver))); + } + + @Test + void collectedParcelsDoNotCountTowardsLockerFullness() throws SQLException { + ParcelRepository repository = this.repository(); + UUID locker = UUID.randomUUID(); + + this.await(repository.save(parcel(UUID.randomUUID(), locker, ParcelStatus.SENT))); + this.await(repository.save(parcel(UUID.randomUUID(), locker, ParcelStatus.DELIVERED))); + this.await(repository.save(parcel(UUID.randomUUID(), locker, ParcelStatus.COLLECTED))); + + assertEquals(2, this.await(repository.countParcelsByDestinationLocker(locker))); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} From 6ddf92e4fba42494f2f003f188b542791e5860eb Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:24:35 +0200 Subject: [PATCH 05/29] feat: add return window, fees, attribute-check flags and return notices to config Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../implementation/MessageConfig.java | 20 +++++ .../implementation/PluginConfig.java | 79 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index a7f007137..4bcd1ea84 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -125,6 +125,26 @@ public static class ParcelMessages extends OkaeriConfig { .chat("&2✔ &a${AMOUNT} has been withdrawn from your account to cover the parcel sending fee.") .sound(SoundEventKeys.ENTITY_EXPERIENCE_ORB_PICKUP) .build(); + public Notice returned = Notice.builder() + .chat("&2✔ &aParcel returned. It is on its way back to the sender.") + .sound(SoundEventKeys.ENTITY_PLAYER_LEVELUP) + .build(); + public Notice cannotReturn = Notice.builder() + .chat("&4✘ &cThis parcel cannot be returned right now. Your items were given back.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice returnItemsMismatch = Notice.builder() + .chat("&4✘ &cThe deposited items do not match the original parcel contents!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice returnWindowExpired = Notice.builder() + .chat("&4✘ &cThe return window for this parcel has expired!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice returnFeeWithdrawn = Notice.builder() + .chat("&2✔ &a${AMOUNT} has been withdrawn from your account to cover the parcel return fee.") + .sound(SoundEventKeys.ENTITY_EXPERIENCE_ORB_PICKUP) + .build(); @Comment({"", "# The parcel info message." }) public Notice parcelInfoMessages = Notice.builder() diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java index 1ae9d486c..083e217a5 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -94,6 +94,25 @@ public static class Settings extends OkaeriConfig { @Comment({"", "# Large parcel fee in in-game currency"}) public double largeParcelFee = 50.0; + + @Comment({"", "# How long after collection a parcel can still be returned.", "# Expired collected parcels are purged periodically."}) + public Duration parcelReturnWindow = Duration.ofDays(7); + + @Comment({"", "# Small parcel return fee in in-game currency"}) + public double smallParcelReturnFee = 5.0; + + @Comment({"", "# Medium parcel return fee in in-game currency"}) + public double mediumParcelReturnFee = 12.5; + + @Comment({"", "# Large parcel return fee in in-game currency"}) + public double largeParcelReturnFee = 25.0; + + @Comment({ + "", + "# Which item attributes must match the original parcel content when a player returns a parcel.", + "# Material types and total amounts must always match; each flag below relaxes one attribute when set to false." + }) + public ReturnChecks returnChecks = new ReturnChecks(); } public static class GuiSettings extends OkaeriConfig { @@ -490,6 +509,66 @@ public static class GuiSettings extends OkaeriConfig { @Comment({ "", "# The lore line showing when the parcel has arrived. Placeholders: {DATE} - arrival date" }) public String parcelArrivedLine = "&aArrived on: &2{DATE}"; + + @Comment({ "", "# The title of the parcel return GUI" }) + public String parcelReturnGuiTitle = "&5Return parcels"; + + @Comment({ "", "# The title of the return deposit GUI" }) + public String parcelReturnDepositGuiTitle = "&5Deposit the parcel items"; + + @Comment({ "", "# The item of the parcel locker return button" }) + public ConfigItem parcelLockerReturnItem = new ConfigItem() + .name("&5↩ Return parcels") + .lore(List.of("&5» &dClick to return a collected parcel.")) + .type(Material.HOPPER) + .glow(true); + + @Comment({ "", "# The item of the parcel in the return GUI" }) + public ConfigItem parcelReturnRowItem = new ConfigItem() + .name("&d{NAME}") + .lore(List.of( + "&6Sender: &e{SENDER}", + "&6Size: &e{SIZE}", + "&6Description: &e{DESCRIPTION}" + ) + ) + .type(Material.CHEST_MINECART); + + @Comment({ "", "# The item displayed in the return GUI when there is nothing to return" }) + public ConfigItem noReturnableParcelsItem = new ConfigItem() + .name("&4✘ &cNo returnable parcels") + .lore(List.of("&cYou don't have any parcels to return.")) + .type(Material.STRUCTURE_VOID); + + @Comment({ "", "# The item of the confirm return button" }) + public ConfigItem confirmReturnItem = new ConfigItem() + .name("&2✔ &aConfirm return") + .lore(List.of("&2» &aDeposit the original items above, then click to return the parcel.")) + .type(Material.GREEN_DYE); + + @Comment({ "", "# The lore line showing how long the parcel can still be returned. Placeholder: {DURATION}" }) + public String returnWindowRemainingLine = "&5Return window: &d{DURATION} left"; + + @Comment({ "", "# The lore line shown when the return window has expired." }) + public String returnWindowExpiredLine = "&cReturn window expired"; + } + + public static class ReturnChecks extends OkaeriConfig { + + @Comment("# Whether durability (damage) must match the original items.") + public boolean checkDurability = true; + + @Comment("# Whether custom display names must match the original items.") + public boolean checkItemName = true; + + @Comment("# Whether enchantments must match the original items.") + public boolean checkEnchantments = true; + + @Comment("# Whether lore must match the original items.") + public boolean checkLore = true; + + @Comment({"# Whether all remaining item data (NBT) must match the original items.", "# When false, only the attributes enabled above are compared."}) + public boolean checkNbt = true; } public static class DiscordSettings extends OkaeriConfig { From 827c54770d14d9bf616c06592025c5573a709e6b Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:23:01 +0200 Subject: [PATCH 06/29] feat: add config-driven item equivalence for parcel returns Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../returns/ReturnItemEquivalence.java | 103 ++++++++++++++++ .../returns/ReturnItemEquivalenceTest.java | 113 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java create mode 100644 src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java new file mode 100644 index 000000000..70d89157b --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java @@ -0,0 +1,103 @@ +package com.eternalcode.parcellockers.returns; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiPredicate; +import net.kyori.adventure.text.Component; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Config-driven equivalence between a deposited item and an original parcel item. + * Material must always match; amounts are compared by {@link ParcelReturnValidator}, + * not here. The relation is symmetric. + */ +public class ReturnItemEquivalence implements BiPredicate { + + private final PluginConfig.ReturnChecks checks; + + public ReturnItemEquivalence(PluginConfig.ReturnChecks checks) { + this.checks = checks; + } + + @Override + public boolean test(ItemStack expected, ItemStack actual) { + if (expected.getType() != actual.getType()) { + return false; + } + if (this.checks.checkNbt) { + if (this.allAttributeChecksEnabled()) { + return expected.isSimilar(actual); + } + return this.normalize(expected).isSimilar(this.normalize(actual)); + } + return this.attributesMatch(expected.getItemMeta(), actual.getItemMeta()); + } + + private boolean allAttributeChecksEnabled() { + return this.checks.checkDurability + && this.checks.checkItemName + && this.checks.checkEnchantments + && this.checks.checkLore; + } + + /** Strips the attributes whose check is disabled so isSimilar ignores them. */ + private ItemStack normalize(ItemStack item) { + ItemStack copy = item.clone(); + ItemMeta meta = copy.getItemMeta(); + if (meta == null) { + return copy; + } + if (!this.checks.checkDurability && meta instanceof Damageable damageable) { + damageable.setDamage(0); + } + if (!this.checks.checkItemName) { + meta.displayName(null); + } + if (!this.checks.checkEnchantments) { + meta.getEnchants().keySet().forEach(meta::removeEnchant); + } + if (!this.checks.checkLore) { + meta.lore(null); + } + copy.setItemMeta(meta); + return copy; + } + + /** checkNbt = false: compare only the enabled attributes. */ + private boolean attributesMatch(ItemMeta expected, ItemMeta actual) { + if (this.checks.checkDurability && damage(expected) != damage(actual)) { + return false; + } + if (this.checks.checkItemName && !Objects.equals(displayName(expected), displayName(actual))) { + return false; + } + if (this.checks.checkEnchantments && !enchants(expected).equals(enchants(actual))) { + return false; + } + if (this.checks.checkLore && !Objects.equals(lore(expected), lore(actual))) { + return false; + } + return true; + } + + private static int damage(ItemMeta meta) { + return meta instanceof Damageable damageable ? damageable.getDamage() : 0; + } + + private static Component displayName(ItemMeta meta) { + return meta == null ? null : meta.displayName(); + } + + private static Map enchants(ItemMeta meta) { + return meta == null ? Map.of() : meta.getEnchants(); + } + + private static List lore(ItemMeta meta) { + return meta == null ? null : meta.lore(); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java new file mode 100644 index 000000000..fcb2529fd --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java @@ -0,0 +1,113 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.Map; +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +class ReturnItemEquivalenceTest { + + private static PluginConfig.ReturnChecks checks(boolean durability, boolean name, boolean enchants, boolean lore, boolean nbt) { + PluginConfig.ReturnChecks checks = new PluginConfig.ReturnChecks(); + checks.checkDurability = durability; + checks.checkItemName = name; + checks.checkEnchantments = enchants; + checks.checkLore = lore; + checks.checkNbt = nbt; + return checks; + } + + private static ItemStack item(Material type) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(type); + return item; + } + + private static ItemMeta damagedMeta(int damage) { + ItemMeta meta = mock(ItemMeta.class, withSettings().extraInterfaces(Damageable.class)); + when(((Damageable) meta).getDamage()).thenReturn(damage); + when(meta.getEnchants()).thenReturn(Map.of()); + return meta; + } + + @Test + void fullyStrictChecksDelegateToIsSimilar() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + when(expected.isSimilar(actual)).thenReturn(true); + + ReturnItemEquivalence equivalence = new ReturnItemEquivalence(checks(true, true, true, true, true)); + assertTrue(equivalence.test(expected, actual)); + + when(expected.isSimilar(actual)).thenReturn(false); + assertFalse(equivalence.test(expected, actual)); + } + + @Test + void differentMaterialNeverMatches() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.IRON_SWORD); + + ReturnItemEquivalence equivalence = new ReturnItemEquivalence(checks(false, false, false, false, false)); + assertFalse(equivalence.test(expected, actual)); + } + + @Test + void durabilityMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + ItemMeta expectedMeta = damagedMeta(0); + ItemMeta actualMeta = damagedMeta(100); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(true, false, false, false, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + @Test + void enchantmentMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + + ItemMeta expectedMeta = mock(ItemMeta.class); + ItemMeta actualMeta = mock(ItemMeta.class); + when(expectedMeta.getEnchants()).thenReturn(enchantsOf("sharpness", 3)); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(false, false, true, false, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + /** + * Mocking Enchantment triggers its static registry lookup (needs a running server), so the + * key is a plain string smuggled through type erasure — getEnchants() map equality is all + * the equivalence compares. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Map enchantsOf(String key, int level) { + return (Map) Map.of(key, level); + } + + @Test + void missingMetaOnBothSidesMatches() { + ItemStack expected = item(Material.COBBLESTONE); + ItemStack actual = item(Material.COBBLESTONE); + when(expected.getItemMeta()).thenReturn(null); + when(actual.getItemMeta()).thenReturn(null); + + assertTrue(new ReturnItemEquivalence(checks(true, true, true, true, false)).test(expected, actual)); + } +} From bee53f5e1ad6e5d5d7051cdf4d5576f87fa8ae95 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:26:59 +0200 Subject: [PATCH 07/29] feat: add multiset return-content validator Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../returns/ParcelReturnValidator.java | 56 +++++++++++++ .../returns/ParcelReturnValidatorTest.java | 82 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java create mode 100644 src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java new file mode 100644 index 000000000..89a63abe1 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java @@ -0,0 +1,56 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiPredicate; +import org.bukkit.inventory.ItemStack; + +/** + * Validates that deposited items are, as a multiset, exactly the original parcel content: + * every deposited stack must be equivalent to some original item, and the total amount per + * equivalence group must match. Stack splitting or merging is irrelevant. + */ +public class ParcelReturnValidator { + + private final BiPredicate equivalence; + + public ParcelReturnValidator(BiPredicate equivalence) { + this.equivalence = equivalence; + } + + public boolean matches(List deposited, List expected) { + List samples = new ArrayList<>(); + List expectedTotals = new ArrayList<>(); + List depositedTotals = new ArrayList<>(); + + for (ItemStack item : expected) { + int index = this.indexOf(samples, item); + if (index < 0) { + samples.add(item); + expectedTotals.add(item.getAmount()); + depositedTotals.add(0); + continue; + } + expectedTotals.set(index, expectedTotals.get(index) + item.getAmount()); + } + + for (ItemStack item : deposited) { + int index = this.indexOf(samples, item); + if (index < 0) { + return false; + } + depositedTotals.set(index, depositedTotals.get(index) + item.getAmount()); + } + + return expectedTotals.equals(depositedTotals); + } + + private int indexOf(List samples, ItemStack item) { + for (int i = 0; i < samples.size(); i++) { + if (this.equivalence.test(samples.get(i), item)) { + return i; + } + } + return -1; + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java new file mode 100644 index 000000000..5c47b0a2b --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java @@ -0,0 +1,82 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.BiPredicate; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class ParcelReturnValidatorTest { + + /** Equivalence by material only — the real attribute logic is tested in ReturnItemEquivalenceTest. */ + private static final BiPredicate BY_MATERIAL = (a, b) -> a.getType() == b.getType(); + + private final ParcelReturnValidator validator = new ParcelReturnValidator(BY_MATERIAL); + + private static ItemStack item(Material type, int amount) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(type); + when(item.getAmount()).thenReturn(amount); + return item; + } + + @Test + void exactSameStacksMatch() { + List expected = List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 64)); + List deposited = List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 64)); + assertTrue(this.validator.matches(deposited, expected)); + } + + @Test + void splitStacksStillMatch() { + List expected = List.of(item(Material.OAK_LOG, 64)); + List deposited = List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)); + assertTrue(this.validator.matches(deposited, expected)); + } + + @Test + void mergedStacksStillMatch() { + List expected = List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)); + List deposited = List.of(item(Material.OAK_LOG, 64)); + assertTrue(this.validator.matches(deposited, expected)); + } + + @Test + void missingAmountFails() { + List expected = List.of(item(Material.DIAMOND, 5)); + List deposited = List.of(item(Material.DIAMOND, 4)); + assertFalse(this.validator.matches(deposited, expected)); + } + + @Test + void extraAmountFails() { + List expected = List.of(item(Material.DIAMOND, 5)); + List deposited = List.of(item(Material.DIAMOND, 6)); + assertFalse(this.validator.matches(deposited, expected)); + } + + @Test + void wrongTypeFails() { + List expected = List.of(item(Material.DIAMOND, 5)); + List deposited = List.of(item(Material.EMERALD, 5)); + assertFalse(this.validator.matches(deposited, expected)); + } + + @Test + void extraForeignItemFails() { + List expected = List.of(item(Material.DIAMOND, 5)); + List deposited = List.of(item(Material.DIAMOND, 5), item(Material.DIRT, 1)); + assertFalse(this.validator.matches(deposited, expected)); + } + + @Test + void emptyDepositAgainstNonEmptyContentFails() { + List expected = List.of(item(Material.DIAMOND, 5)); + assertFalse(this.validator.matches(List.of(), expected)); + } +} From 264d5c3260e210ef6aaaebba9aafa749f34682e8 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:32:02 +0200 Subject: [PATCH 08/29] feat: keep collected parcels for the return window instead of deleting them Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../parcellockers/ParcelLockers.java | 3 + .../parcel/service/ParcelService.java | 9 +++ .../parcel/service/ParcelServiceImpl.java | 72 +++++++++++++------ 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index c7342143b..0623f647e 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -44,6 +44,7 @@ import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; import com.eternalcode.parcellockers.updater.UpdaterService; import com.eternalcode.parcellockers.user.UserManager; import com.eternalcode.parcellockers.user.UserManagerImpl; @@ -122,12 +123,14 @@ public void onEnable() { DeliveryRepositoryOrmLite deliveryRepository = new DeliveryRepositoryOrmLite(databaseManager, scheduler); ItemStorageRepository itemStorageRepository = new ItemStorageRepositoryOrmLite(databaseManager, scheduler); UserRepository userRepository = new UserRepositoryOrmLite(databaseManager, scheduler); + CollectedParcelRepositoryOrmLite collectedParcelRepository = new CollectedParcelRepositoryOrmLite(databaseManager, scheduler); // service and managers ParcelService parcelService = new ParcelServiceImpl( noticeService, parcelRepository, parcelContentRepository, + collectedParcelRepository, scheduler, config, this.economy, diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java index abaa9cb55..364afc21f 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -44,6 +44,15 @@ public interface ParcelService { */ CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page); + /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ + CompletableFuture> getReturnable(UUID receiver, Page page); + + /** + * Atomically turns a COLLECTED parcel into its reverse SENT shipment. Returns false when + * the parcel was already returned or purged in the meantime. + */ + CompletableFuture markReturned(Parcel returned); + CompletableFuture> getAll(Page page); CompletableFuture delete(UUID uuid); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index 27a687f88..4259aea10 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -10,15 +10,19 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.event.ParcelCollectEvent; import com.eternalcode.parcellockers.parcel.event.ParcelSendEvent; import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.base.Preconditions; +import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -43,6 +47,7 @@ public class ParcelServiceImpl implements ParcelService { private final NoticeService noticeService; private final ParcelRepository parcelRepository; private final ParcelContentRepository parcelContentRepository; + private final CollectedParcelRepository collectedParcelRepository; private final Scheduler scheduler; private final PluginConfig config; private final Economy economy; @@ -54,6 +59,7 @@ public ParcelServiceImpl( NoticeService noticeService, ParcelRepository parcelRepository, ParcelContentRepository parcelContentRepository, + CollectedParcelRepository collectedParcelRepository, Scheduler scheduler, PluginConfig config, Economy economy, @@ -62,6 +68,7 @@ public ParcelServiceImpl( this.noticeService = noticeService; this.parcelRepository = parcelRepository; this.parcelContentRepository = parcelContentRepository; + this.collectedParcelRepository = collectedParcelRepository; this.scheduler = scheduler; this.config = config; this.economy = economy; @@ -220,8 +227,11 @@ public CompletableFuture collect(Player player, Parcel parcel) { CompletableFuture result = new CompletableFuture<>(); // Re-check inventory space on the main thread (the previous async check was a TOCTOU), - // then delete the parcel BEFORE handing the items back so it cannot be collected twice. - // Items are only given once the delete is confirmed, so a failed delete never destroys them. + // then flip the status BEFORE handing the items back so the parcel cannot be collected + // twice. The parcel and content rows are kept: they are the snapshot a later return is + // validated against. The collected_parcels row is written first so that a successful + // flip always has a collection timestamp; a stray row from a failed flip is ignored by + // the purge task (it only purges parcels that are actually COLLECTED). this.scheduler.run(() -> { if (!canHold(player, items)) { this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.noInventorySpace); @@ -229,31 +239,17 @@ public CompletableFuture collect(Player player, Parcel parcel) { return; } - this.parcelRepository.delete(parcel) - .thenCompose(deleted -> { - if (!deleted) { - return CompletableFuture.completedFuture(false); - } - // The parcel is gone, so it can never be collected again; deleting its content - // is best-effort cleanup and must not block handing the items back. Otherwise a - // failed content delete would lose the items permanently. - return this.parcelContentRepository.delete(parcel.uuid()) - .handle((contentDeleted, throwable) -> { - if (throwable != null) { - this.server.getLogger().warning("Failed to delete content for collected parcel " - + parcel.uuid() + ": " + throwable.getMessage()); - } - return true; - }); - }) - .thenAccept(removed -> { - if (!removed) { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.databaseError); + this.collectedParcelRepository.save(new CollectedParcel(parcel.uuid(), Instant.now())) + .thenCompose(saved -> this.parcelRepository.markCollected(parcel.uuid())) + .thenAccept(marked -> { + if (!Boolean.TRUE.equals(marked)) { + // Someone else collected it first (or the status changed under us). + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); result.complete(null); return; } - this.parcelsByUuid.invalidate(parcel.uuid()); + this.parcelsByUuid.put(parcel.uuid(), withStatus(parcel, ParcelStatus.COLLECTED)); this.scheduler.run(() -> { items.forEach(item -> ItemUtil.giveItem(player, item)); this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.collected); @@ -271,6 +267,12 @@ public CompletableFuture collect(Player player, Parcel parcel) { }); } + private static Parcel withStatus(Parcel parcel, ParcelStatus status) { + return new Parcel(parcel.uuid(), parcel.sender(), parcel.name(), parcel.description(), + parcel.priority(), parcel.receiver(), parcel.size(), parcel.entryLocker(), + parcel.destinationLocker(), status); + } + @Override public CompletableFuture> get(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); @@ -321,6 +323,30 @@ public CompletableFuture> getCollectible(UUID receiver, UUID }); } + @Override + public CompletableFuture> getReturnable(UUID receiver, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findReturnable(receiver, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + + @Override + public CompletableFuture markReturned(Parcel returned) { + Objects.requireNonNull(returned, "Returned parcel cannot be null"); + + return this.parcelRepository.markReturned(returned).thenApply(updated -> { + if (updated) { + this.parcelsByUuid.put(returned.uuid(), returned); + } + return updated; + }); + } + @Override public CompletableFuture> getAll(Page page) { Objects.requireNonNull(page, "Page cannot be null"); From 54596bd9c8495ad521a9ffdf72051926fc112f99 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:40:47 +0200 Subject: [PATCH 09/29] feat: add parcel return service and ParcelReturnEvent Orchestrates the return of a COLLECTED parcel: re-verifies status/receiver, checks the return window, validates deposited items against the stored content snapshot, checks entry-locker capacity, charges the return fee (bypassable), flips the parcel into a reverse SENT shipment via ParcelService.markReturned, and schedules the normal delivery task. Every abort path hands the deposited items back and sends a specific notice; the fee is refunded on any failure after it was charged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../parcellockers/ParcelLockers.java | 18 ++ .../parcel/event/ParcelReturnEvent.java | 30 +++ .../returns/ParcelReturnService.java | 234 ++++++++++++++++++ .../returns/ParcelReturnServiceTest.java | 33 +++ 4 files changed, 315 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java create mode 100644 src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 0623f647e..16ae00ae3 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -44,6 +44,9 @@ import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; +import com.eternalcode.parcellockers.returns.ParcelReturnService; +import com.eternalcode.parcellockers.returns.ParcelReturnValidator; +import com.eternalcode.parcellockers.returns.ReturnItemEquivalence; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; import com.eternalcode.parcellockers.updater.UpdaterService; import com.eternalcode.parcellockers.user.UserManager; @@ -155,6 +158,21 @@ public void onEnable() { noticeService ); + ParcelReturnValidator returnValidator = new ParcelReturnValidator(new ReturnItemEquivalence(config.settings.returnChecks)); + ParcelReturnService parcelReturnService = new ParcelReturnService( + parcelService, + parcelContentManager, + collectedParcelRepository, + deliveryManager, + lockerManager, + returnValidator, + scheduler, + config, + noticeService, + this.economy, + server + ); + // guis TriumphGui.init(this); GuiManager guiManager = new GuiManager( diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java new file mode 100644 index 000000000..c388b0d4d --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java @@ -0,0 +1,30 @@ +package com.eternalcode.parcellockers.parcel.event; + +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +public class ParcelReturnEvent extends CancellableEvent { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final Parcel parcel; + + public ParcelReturnEvent(Parcel parcel) { + this.parcel = parcel; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + public Parcel getParcel() { + return this.parcel; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java new file mode 100644 index 000000000..078bb21cf --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -0,0 +1,234 @@ +package com.eternalcode.parcellockers.returns; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.provider.NoticeProvider; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.event.ParcelReturnEvent; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +import net.milkbowl.vault.economy.Economy; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** + * Orchestrates returning a collected parcel: validates the deposited items against the stored + * content snapshot, charges the return fee, flips the parcel into a reverse SENT shipment and + * schedules the normal delivery task. Every abort path hands the deposited items back. + */ +public class ParcelReturnService { + + private static final Logger LOGGER = Logger.getLogger(ParcelReturnService.class.getName()); + private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; + private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; + + private final ParcelService parcelService; + private final ParcelContentManager parcelContentManager; + private final CollectedParcelRepository collectedParcelRepository; + private final DeliveryManager deliveryManager; + private final LockerManager lockerManager; + private final ParcelReturnValidator validator; + private final Scheduler scheduler; + private final PluginConfig config; + private final NoticeService noticeService; + private final Economy economy; + private final Server server; + + public ParcelReturnService( + ParcelService parcelService, + ParcelContentManager parcelContentManager, + CollectedParcelRepository collectedParcelRepository, + DeliveryManager deliveryManager, + LockerManager lockerManager, + ParcelReturnValidator validator, + Scheduler scheduler, + PluginConfig config, + NoticeService noticeService, + Economy economy, + Server server + ) { + this.parcelService = parcelService; + this.parcelContentManager = parcelContentManager; + this.collectedParcelRepository = collectedParcelRepository; + this.deliveryManager = deliveryManager; + this.lockerManager = lockerManager; + this.validator = validator; + this.scheduler = scheduler; + this.config = config; + this.noticeService = noticeService; + this.economy = economy; + this.server = server; + } + + public static boolean isWithinReturnWindow(CollectedParcel collected, Duration window, Instant now) { + return collected.collectedAt().plus(window).isAfter(now); + } + + public CompletableFuture> getCollectedInfo(UUID parcelId) { + Objects.requireNonNull(parcelId, "Parcel UUID cannot be null"); + return this.collectedParcelRepository.find(parcelId); + } + + public CompletableFuture returnParcel(Player player, Parcel parcel, List deposited) { + Objects.requireNonNull(player, "Player cannot be null"); + Objects.requireNonNull(parcel, "Parcel cannot be null"); + Objects.requireNonNull(deposited, "Deposited items cannot be null"); + + ParcelReturnEvent event = new ParcelReturnEvent(parcel); + this.server.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + + return this.parcelService.get(parcel.uuid()).thenCompose(optionalParcel -> { + if (optionalParcel.isEmpty() + || optionalParcel.get().status() != ParcelStatus.COLLECTED + || !player.getUniqueId().equals(optionalParcel.get().receiver())) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + Parcel current = optionalParcel.get(); + + return this.collectedParcelRepository.find(current.uuid()).thenCompose(optionalCollected -> { + if (optionalCollected.isEmpty() + || !isWithinReturnWindow(optionalCollected.get(), this.config.settings.parcelReturnWindow, Instant.now())) { + return this.abort(player, deposited, messages -> messages.parcel.returnWindowExpired); + } + + return this.parcelContentManager.get(current.uuid()).thenCompose(optionalContent -> { + if (optionalContent.isEmpty()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + if (!this.validator.matches(deposited, optionalContent.get().items())) { + return this.abort(player, deposited, messages -> messages.parcel.returnItemsMismatch); + } + + // The return ships to the original entry locker. + return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { + if (Boolean.TRUE.equals(isFull)) { + return this.abort(player, deposited, messages -> messages.parcel.lockerFull); + } + return this.execute(player, current, deposited); + }); + }); + }); + }).exceptionally(throwable -> { + LOGGER.severe("Failed to return parcel " + parcel.uuid() + " for " + player.getName() + ": " + throwable.getMessage()); + this.giveBack(player, deposited); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotReturn); + return null; + }); + } + + private CompletableFuture execute(Player player, Parcel current, List deposited) { + double chargedFee = 0; + if (!player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { + double fee = this.returnFeeFor(current.size()); + if (fee > 0) { + boolean success = this.economy.withdrawPlayer(player, fee).transactionSuccess(); + String formattedFee = String.format("%.2f", fee); + if (!success) { + this.noticeService.create() + .notice(messages -> messages.parcel.insufficientFunds) + .player(player.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, formattedFee) + .send(); + this.giveBack(player, deposited); + return CompletableFuture.completedFuture(null); + } + chargedFee = fee; + this.noticeService.create() + .notice(messages -> messages.parcel.returnFeeWithdrawn) + .player(player.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, formattedFee) + .send(); + } + } + double refundableFee = chargedFee; + + Parcel returned = new Parcel(current.uuid(), current.receiver(), current.name(), + current.description(), current.priority(), current.sender(), current.size(), + current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); + + List depositedCopy = deposited.stream().map(ItemStack::clone).toList(); + + // Content is overwritten with the actually-deposited items first (they may legitimately + // differ from the snapshot when check flags are relaxed); only then the status flip makes + // the parcel a live shipment. markReturned failing means a concurrent return/purge won. + return this.parcelContentManager.update(current.uuid(), depositedCopy) + .thenCompose(updated -> this.parcelService.markReturned(returned)) + .thenCompose(marked -> { + if (!Boolean.TRUE.equals(marked)) { + this.refund(player, refundableFee); + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + + // Best-effort cleanup: a leftover row is ignored by the purge task because the + // parcel is no longer COLLECTED. + this.collectedParcelRepository.delete(current.uuid()).exceptionally(throwable -> { + LOGGER.warning("Failed to delete collected_parcels row for returned parcel " + + current.uuid() + ": " + throwable.getMessage()); + return false; + }); + + Duration delay = returned.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + this.deliveryManager.create(returned.uuid(), Instant.now().plus(delay)); + this.scheduler.runLaterAsync( + new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), + delay); + + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + return CompletableFuture.completedFuture(null); + }) + .exceptionally(throwable -> { + this.refund(player, refundableFee); + this.giveBack(player, deposited); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotReturn); + LOGGER.severe("Failed to execute return of parcel " + current.uuid() + ": " + throwable.getMessage()); + return null; + }); + } + + private double returnFeeFor(ParcelSize size) { + return switch (size) { + case SMALL -> this.config.settings.smallParcelReturnFee; + case MEDIUM -> this.config.settings.mediumParcelReturnFee; + case LARGE -> this.config.settings.largeParcelReturnFee; + }; + } + + private void refund(Player player, double fee) { + if (fee > 0) { + this.economy.depositPlayer(player, fee); + } + } + + private CompletableFuture abort(Player player, List deposited, NoticeProvider notice) { + this.giveBack(player, deposited); + this.noticeService.player(player.getUniqueId(), notice); + return CompletableFuture.completedFuture(null); + } + + private void giveBack(Player player, List items) { + this.scheduler.run(() -> items.forEach(item -> ItemUtil.giveItem(player, item))); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java new file mode 100644 index 000000000..ab1a59b9f --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -0,0 +1,33 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ParcelReturnServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-02T12:00:00Z"); + private static final Duration WINDOW = Duration.ofDays(7); + + @Test + void withinWindowJustAfterCollection() { + CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(Duration.ofHours(1))); + assertTrue(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); + } + + @Test + void outsideWindowAfterExpiry() { + CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(Duration.ofDays(8))); + assertFalse(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); + } + + @Test + void exactExpiryInstantIsOutsideWindow() { + CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(WINDOW)); + assertFalse(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); + } +} From f47e28a098cfbb378dd947a6a8e59953cfa38e80 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:48:50 +0200 Subject: [PATCH 10/29] fix: keep post-commit return failures away from the refund path After markReturned succeeds the parcel is a live shipment; a throwing DeliveryManager.create (stale cached delivery from the original trip) must not trigger the refund/give-back recovery, which would duplicate the deposited items. Schedule the send task first and upsert the delivery timestamp instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../returns/ParcelReturnService.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index 078bb21cf..cb9e82b4d 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -180,6 +180,10 @@ private CompletableFuture execute(Player player, Parcel current, List messages.parcel.cannotReturn); } + // Past the commit point: the parcel is now a live SENT shipment and its content IS the + // deposited items. Nothing below may route to the refund/give-back recovery — undoing + // here would duplicate the items. + // Best-effort cleanup: a leftover row is ignored by the purge task because the // parcel is no longer COLLECTED. this.collectedParcelRepository.delete(current.uuid()).exceptionally(throwable -> { @@ -191,11 +195,26 @@ private CompletableFuture execute(Player player, Parcel current, List { + LOGGER.severe("Failed to persist delivery for returned parcel " + returned.uuid() + + " (send task scheduled in-memory; a restart before it fires may strand the parcel): " + + throwable.getMessage()); + return null; + }); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); return CompletableFuture.completedFuture(null); }) From ad20fff4a5a41c20298f43581b2dc71692305b40 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:52:19 +0200 Subject: [PATCH 11/29] feat: purge collected parcels after the return window expires Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../parcellockers/ParcelLockers.java | 7 ++ .../returns/task/ReturnWindowPurgeTask.java | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 16ae00ae3..d09349c5e 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -48,6 +48,7 @@ import com.eternalcode.parcellockers.returns.ParcelReturnValidator; import com.eternalcode.parcellockers.returns.ReturnItemEquivalence; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.task.ReturnWindowPurgeTask; import com.eternalcode.parcellockers.updater.UpdaterService; import com.eternalcode.parcellockers.user.UserManager; import com.eternalcode.parcellockers.user.UserManagerImpl; @@ -173,6 +174,12 @@ public void onEnable() { server ); + scheduler.timerAsync( + new ReturnWindowPurgeTask(parcelService, collectedParcelRepository, config), + Duration.ofSeconds(30), + Duration.ofMinutes(30) + ); + // guis TriumphGui.init(this); GuiManager guiManager = new GuiManager( diff --git a/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java b/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java new file mode 100644 index 000000000..56281fe7c --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java @@ -0,0 +1,70 @@ +package com.eternalcode.parcellockers.returns.task; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +/** + * Deletes collected parcels whose return window expired: the parcel row, its content row and the + * collected_parcels row. Runs periodically; failures are logged and retried on the next run. + */ +public class ReturnWindowPurgeTask implements Runnable { + + private static final Logger LOGGER = Logger.getLogger(ReturnWindowPurgeTask.class.getName()); + + /** Extra slack past the window so an in-flight return at the expiry boundary cannot race the purge. */ + private static final Duration GRACE = Duration.ofMinutes(5); + + private final ParcelService parcelService; + private final CollectedParcelRepository collectedParcelRepository; + private final PluginConfig config; + + public ReturnWindowPurgeTask( + ParcelService parcelService, + CollectedParcelRepository collectedParcelRepository, + PluginConfig config + ) { + this.parcelService = parcelService; + this.collectedParcelRepository = collectedParcelRepository; + this.config = config; + } + + @Override + public void run() { + Instant cutoff = Instant.now().minus(this.config.settings.parcelReturnWindow).minus(GRACE); + + this.collectedParcelRepository.findExpired(cutoff) + .thenAccept(expired -> expired.forEach(this::purge)) + .exceptionally(throwable -> { + LOGGER.severe("Failed to query expired collected parcels: " + throwable.getMessage()); + return null; + }); + } + + private void purge(CollectedParcel collected) { + this.parcelService.get(collected.parcel()) + .thenCompose(optionalParcel -> { + if (optionalParcel.isPresent() && optionalParcel.get().status() == ParcelStatus.COLLECTED) { + // Delete the parcel (and content) first; the row is only removed once that + // succeeded so a failed delete is retried on the next run. + return this.parcelService.delete(collected.parcel()) + .thenCompose(deleted -> Boolean.TRUE.equals(deleted) + ? this.collectedParcelRepository.delete(collected.parcel()) + : CompletableFuture.completedFuture(false)); + } + // Stray row: the parcel is gone or is a live shipment again — drop only the row. + return this.collectedParcelRepository.delete(collected.parcel()); + }) + .exceptionally(throwable -> { + LOGGER.warning("Failed to purge expired collected parcel " + collected.parcel() + + " (will retry next run): " + throwable.getMessage()); + return false; + }); + } +} From 3e452311eddd9e2fb66182f71c2a59435b624275 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:01:31 +0200 Subject: [PATCH 12/29] feat: add return button, return list GUI and deposit GUI (GH-69) Wires the last piece of the parcel return flow into the GUI layer: GuiManager gains getReturnableParcels/getCollectedInfo/returnParcel passthroughs to ParcelService/ParcelReturnService, ReturnGui lists a player's collected parcels with a remaining-return-window lore line, ReturnDepositGui lets them deposit the original items and confirms the return (or gives everything back on close), and LockerGui gets a "Return parcels" button between collect and send. Threads the return window Duration through SendingGui/ItemStorageGui as well, since they rebuild LockerGui/each other and would otherwise fail to compile after LockerGui's constructor changed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../parcellockers/ParcelLockers.java | 6 +- .../parcellockers/gui/GuiManager.java | 19 +- .../implementation/locker/ItemStorageGui.java | 9 +- .../gui/implementation/locker/LockerGui.java | 18 +- .../locker/ReturnDepositGui.java | 109 +++++++++++ .../gui/implementation/locker/ReturnGui.java | 173 ++++++++++++++++++ .../gui/implementation/locker/SendingGui.java | 12 +- 7 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java create mode 100644 src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index d09349c5e..5b32696fc 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -190,7 +190,8 @@ public void onEnable() { parcelDispatchService, parcelContentManager, deliveryManager, - config.settings.allowCollectingFromAnyLocker + config.settings.allowCollectingFromAnyLocker, + parcelReturnService ); MainGui mainGUI = new MainGui( @@ -205,7 +206,8 @@ public void onEnable() { scheduler, config.guiSettings, guiManager, - noticeService + noticeService, + config.settings.parcelReturnWindow ); AdminParcelService adminParcelService = new AdminParcelService( diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 5ed683ef6..0c560a8e2 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -12,6 +12,8 @@ import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.ParcelReturnService; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.user.User; @@ -35,6 +37,7 @@ public class GuiManager { private final ParcelContentManager parcelContentManager; private final DeliveryManager deliveryManager; private final boolean allowCollectingFromAnyLocker; + private final ParcelReturnService parcelReturnService; public GuiManager( ParcelService parcelService, @@ -44,7 +47,8 @@ public GuiManager( ParcelDispatchService parcelDispatchService, ParcelContentManager parcelContentManager, DeliveryManager deliveryManager, - boolean allowCollectingFromAnyLocker + boolean allowCollectingFromAnyLocker, + ParcelReturnService parcelReturnService ) { this.parcelService = parcelService; this.lockerManager = lockerManager; @@ -54,6 +58,7 @@ public GuiManager( this.parcelContentManager = parcelContentManager; this.deliveryManager = deliveryManager; this.allowCollectingFromAnyLocker = allowCollectingFromAnyLocker; + this.parcelReturnService = parcelReturnService; } /** @@ -157,4 +162,16 @@ public CompletableFuture deleteAllParcels(CommandSender sender, NoticeServ public CompletableFuture deleteAllLockers(CommandSender sender, NoticeService noticeService) { return this.lockerManager.deleteAll(sender, noticeService); } + + public CompletableFuture> getReturnableParcels(UUID receiver, Page page) { + return this.parcelService.getReturnable(receiver, page); + } + + public CompletableFuture> getCollectedInfo(UUID parcelId) { + return this.parcelReturnService.getCollectedInfo(parcelId); + } + + public void returnParcel(Player player, Parcel parcel, List deposited) { + this.parcelReturnService.returnParcel(player, parcel, deposited); + } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java index 0dc1d6eab..a7aef9406 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java @@ -11,6 +11,7 @@ import dev.triumphteam.gui.guis.Gui; import dev.triumphteam.gui.guis.GuiItem; import dev.triumphteam.gui.guis.StorageGui; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; @@ -26,6 +27,7 @@ public class ItemStorageGui { private final GuiManager guiManager; private final NoticeService noticeService; private final SendingGuiState state; + private final Duration returnWindow; public ItemStorageGui( Scheduler scheduler, @@ -33,7 +35,8 @@ public ItemStorageGui( MiniMessage miniMessage, GuiManager guiManager, NoticeService noticeService, - SendingGuiState state + SendingGuiState state, + Duration returnWindow ) { this.scheduler = scheduler; this.guiSettings = guiSettings; @@ -41,6 +44,7 @@ public ItemStorageGui( this.guiManager = guiManager; this.noticeService = noticeService; this.state = state; + this.returnWindow = returnWindow; } void show(Player player, ParcelSize size) { @@ -111,7 +115,8 @@ void show(Player player, ParcelSize size) { this.miniMessage, this.noticeService, this.guiManager, - this.state + this.state, + this.returnWindow ).show(player)) .exceptionally(throwable -> { // Persisting the staged items failed; hand them back so they are not lost. diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java index 3ee62762a..ff90417e3 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java @@ -9,6 +9,7 @@ import com.eternalcode.parcellockers.notification.NoticeService; import dev.triumphteam.gui.guis.Gui; import dev.triumphteam.gui.guis.GuiItem; +import java.time.Duration; import java.util.UUID; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -21,17 +22,19 @@ public class LockerGui implements GuiView { private final GuiSettings guiSettings; private final GuiManager guiManager; private final NoticeService noticeService; + private final Duration returnWindow; public LockerGui( MiniMessage miniMessage, Scheduler scheduler, GuiSettings guiSettings, GuiManager guiManager, - NoticeService noticeService + NoticeService noticeService, Duration returnWindow ) { this.miniMessage = miniMessage; this.scheduler = scheduler; this.guiSettings = guiSettings; this.guiManager = guiManager; this.noticeService = noticeService; + this.returnWindow = returnWindow; } public void show(Player player, UUID entryLocker) { @@ -53,14 +56,25 @@ public void show(Player player, UUID entryLocker) { entryLocker ); + ReturnGui returnGui = new ReturnGui( + this.guiSettings, + this.scheduler, + this.guiManager, + this.miniMessage, + this.noticeService, + this.returnWindow + ); + gui.setItem(21, this.guiSettings.parcelLockerCollectItem.toGuiItem(event -> collectionGui.show(player))); + gui.setItem(22, this.guiSettings.parcelLockerReturnItem.toGuiItem(event -> returnGui.show(player))); gui.setItem(23, this.guiSettings.parcelLockerSendItem.toGuiItem(event -> new SendingGui( this.scheduler, this.guiSettings, this.miniMessage, this.noticeService, this.guiManager, - new SendingGuiState().entryLocker(entryLocker) + new SendingGuiState().entryLocker(entryLocker), + this.returnWindow ).show(player, entryLocker))); gui.open(player); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java new file mode 100644 index 000000000..85e0e8610 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java @@ -0,0 +1,109 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.StorageGui; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.IntStream; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** + * Deposit GUI for a parcel return: the player places the original items, then confirms. + * Confirm hands the stacks to the return service (which gives them back on any failure); + * closing without confirming gives everything back immediately. + */ +public class ReturnDepositGui { + + private final Scheduler scheduler; + private final GuiSettings guiSettings; + private final MiniMessage miniMessage; + private final GuiManager guiManager; + private final NoticeService noticeService; + private final Parcel parcel; + + public ReturnDepositGui( + Scheduler scheduler, + GuiSettings guiSettings, + MiniMessage miniMessage, + GuiManager guiManager, + NoticeService noticeService, + Parcel parcel + ) { + this.scheduler = scheduler; + this.guiSettings = guiSettings; + this.miniMessage = miniMessage; + this.guiManager = guiManager; + this.noticeService = noticeService; + this.parcel = parcel; + } + + void show(Player player) { + int rows = switch (this.parcel.size()) { + case SMALL -> 2; + case MEDIUM -> 3; + case LARGE -> 4; + }; + + StorageGui gui = Gui.storage() + .title(this.miniMessage.deserialize(this.guiSettings.parcelReturnDepositGuiTitle)) + .rows(rows) + .create(); + + GuiItem backgroundItem = this.guiSettings.mainGuiBackgroundItem.toGuiItem(event -> event.setCancelled(true)); + IntStream.rangeClosed(1, 9).forEach(i -> gui.setItem(gui.getRows(), i, backgroundItem)); + + AtomicBoolean confirmed = new AtomicBoolean(false); + + GuiItem confirmItem = this.guiSettings.confirmReturnItem.toGuiItem(event -> { + event.setCancelled(true); + + List deposited = this.takeDepositedItems(gui); + if (deposited.isEmpty()) { + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returnItemsMismatch); + return; + } + + confirmed.set(true); + gui.close(player); + this.guiManager.returnParcel(player, this.parcel, deposited); + }); + gui.setItem(gui.getRows(), 5, confirmItem); + + gui.setCloseGuiAction(event -> { + if (confirmed.get()) { + return; + } + // Closed without confirming: give every deposited stack back. + List leftovers = this.takeDepositedItems(gui); + this.scheduler.run(() -> leftovers.forEach(item -> ItemUtil.giveItem(player, item))); + }); + + this.scheduler.run(() -> gui.open(player)); + } + + /** Snapshots and clears the deposit slots (everything above the bottom control row). */ + private List takeDepositedItems(StorageGui gui) { + ItemStack[] contents = gui.getInventory().getContents(); + List items = new ArrayList<>(); + + for (int i = 0; i < contents.length - 9; i++) { + ItemStack item = contents[i]; + if (item == null || item.isEmpty()) { + continue; + } + items.add(item.clone()); + gui.getInventory().setItem(i, null); + } + return items; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java new file mode 100644 index 000000000..cf250a5be --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java @@ -0,0 +1,173 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.gui.PaginatedGuiRefresher; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.util.PlaceholderUtil; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.util.DurationUtil; +import com.eternalcode.parcellockers.util.MaterialUtil; +import com.spotify.futures.CompletableFutures; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class ReturnGui implements GuiView { + + private static final int WIDTH = 7; + private static final int HEIGHT = 4; + private static final Page FIRST_PAGE = new Page(0, WIDTH * HEIGHT); + + private final GuiSettings guiSettings; + private final Scheduler scheduler; + private final GuiManager guiManager; + private final MiniMessage miniMessage; + private final NoticeService noticeService; + private final Duration returnWindow; + + public ReturnGui( + GuiSettings guiSettings, + Scheduler scheduler, + GuiManager guiManager, + MiniMessage miniMessage, + NoticeService noticeService, + Duration returnWindow + ) { + this.guiSettings = guiSettings; + this.scheduler = scheduler; + this.guiManager = guiManager; + this.miniMessage = miniMessage; + this.noticeService = noticeService; + this.returnWindow = returnWindow; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + Component guiTitle = this.miniMessage.deserialize(this.guiSettings.parcelReturnGuiTitle); + ConfigItem rowItem = this.guiSettings.parcelReturnRowItem; + + PaginatedGui gui = Gui.paginated() + .rows(6) + .disableAllInteractions() + .title(guiTitle) + .create(); + + this.setupStaticItems(player, gui); + + this.guiManager.getReturnableParcels(player.getUniqueId(), page).thenAccept(result -> { + if (result == null || result.items().isEmpty()) { + gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + + PaginatedGuiRefresher refresher = new PaginatedGuiRefresher(gui); + + this.setupNavigation(gui, page, result, player, this.guiSettings); + + result.items().stream() + .map(parcel -> this.createParcelItemAsync(parcel, rowItem, player)) + .collect(CompletableFutures.joinList()) + .thenAccept(suppliers -> { + if (suppliers.isEmpty()) { + gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); + this.scheduler.run(() -> gui.open(player)); + return; + } + for (Supplier supplier : suppliers) { + refresher.addItem(supplier); + } + this.scheduler.run(() -> gui.open(player)); + }).exceptionally(FutureHandler::handleException); + }).exceptionally(FutureHandler::handleException); + } + + private void setupStaticItems(Player player, PaginatedGui gui) { + GuiItem closeItem = this.guiSettings.closeItem.toGuiItem(event -> gui.close(player)); + GuiItem cornerItem = this.guiSettings.cornerItem.toGuiItem(); + GuiItem backgroundItem = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + + for (int cornerSlot : CORNER_SLOTS) { + gui.setItem(cornerSlot, cornerItem); + } + + for (int borderSlot : BORDER_SLOTS) { + gui.setItem(borderSlot, backgroundItem); + } + + gui.setItem(49, closeItem); + } + + private CompletableFuture> createParcelItemAsync( + Parcel parcel, + ConfigItem rowItem, + Player player + ) { + CompletableFuture> loreFuture = + PlaceholderUtil.replaceParcelPlaceholdersAsync(parcel, rowItem.lore(), this.guiManager); + CompletableFuture> contentFuture = this.guiManager.getParcelContent(parcel.uuid()) + .thenApply(optional -> optional.map(content -> content.items()).orElse(List.of())); + CompletableFuture windowLineFuture = this.guiManager.getCollectedInfo(parcel.uuid()) + .thenApply(optional -> optional + .map(collected -> { + Duration remaining = Duration.between(Instant.now(), collected.collectedAt().plus(this.returnWindow)); + return remaining.isNegative() || remaining.isZero() + ? this.guiSettings.returnWindowExpiredLine + : this.guiSettings.returnWindowRemainingLine.replace("{DURATION}", DurationUtil.format(remaining)); + }) + .orElse(this.guiSettings.returnWindowExpiredLine)); + + return CompletableFutures.combine(loreFuture, contentFuture, windowLineFuture, (processedLore, items, windowLine) -> { + Supplier supplier = () -> { + ConfigItem item = rowItem.clone(); + item.name(item.name().replace("{NAME}", parcel.name())); + + List lore = new ArrayList<>(processedLore); + lore.add(windowLine); + if (!items.isEmpty()) { + lore.add(this.guiSettings.parcelItemsCollectionGui); + for (ItemStack itemStack : items) { + lore.add(this.guiSettings.parcelItemCollectionFormat + .replace("{ITEM}", MaterialUtil.format(itemStack.getType())) + .replace("{AMOUNT}", Integer.toString(itemStack.getAmount())) + ); + } + } + + item.lore(lore); + item.glow(true); + + return item.toGuiItem(event -> new ReturnDepositGui( + this.scheduler, + this.guiSettings, + this.miniMessage, + this.guiManager, + this.noticeService, + parcel + ).show(player)); + }; + return supplier; + }).toCompletableFuture(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java index 31b3d9f05..dbb88960f 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java @@ -21,6 +21,7 @@ import io.papermc.paper.registry.data.dialog.action.DialogAction; import io.papermc.paper.registry.data.dialog.input.DialogInput; import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -53,6 +54,7 @@ public class SendingGui implements GuiView { private final GuiManager guiManager; private final SendingGuiState state; + private final Duration returnWindow; private Gui gui; @@ -62,7 +64,8 @@ public SendingGui( MiniMessage miniMessage, NoticeService noticeService, GuiManager guiManager, - SendingGuiState state + SendingGuiState state, + Duration returnWindow ) { this.scheduler = scheduler; this.guiSettings = guiSettings; @@ -70,6 +73,7 @@ public SendingGui( this.noticeService = noticeService; this.guiManager = guiManager; this.state = state; + this.returnWindow = returnWindow; } public void show(Player player, UUID entryLocker) { @@ -192,7 +196,8 @@ public void show(Player player) { this.miniMessage, this.guiManager, this.noticeService, - this.state + this.state, + this.returnWindow ); this.guiManager.getItemStorage(player.getUniqueId()).thenAccept(result -> { int slotsSize = result.items().size(); @@ -251,7 +256,8 @@ public void show(Player player) { this.scheduler, this.guiSettings, this.guiManager, - this.noticeService + this.noticeService, + this.returnWindow ).show(player, this.state.entryLocker())); ConfigItem smallButton = this.guiSettings.smallParcelSizeItem; From 2a9f44f104ff858d7c3173be6793c6ef321af754 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:03:06 +0200 Subject: [PATCH 13/29] fix: refuse status changes on COLLECTED parcels (GH-69) Flipping a COLLECTED parcel's status back to SENT re-armed a delivery even though the content row still held the collection snapshot, letting an admin one-click the receiver into collecting the same items twice. AdminParcelService.changeStatus now rejects any status change while a parcel is COLLECTED, and AdminParcelEditGui no longer offers the toggle for one, so the click surfaces the new admin.statusLocked notice instead of a silent no-op. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../implementation/MessageConfig.java | 1 + .../admin/AdminParcelEditGui.java | 6 ++++++ .../parcel/service/AdminParcelService.java | 9 ++++++++ .../parcel/service/EditResult.java | 2 +- .../service/AdminParcelServiceTest.java | 21 +++++++++++++++++++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index 4bcd1ea84..06f9ed6bc 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -209,6 +209,7 @@ public static class AdminMessages extends OkaeriConfig { public Notice teleportWorldMissing = Notice.chat("&4✘ &cThat locker's world is not loaded."); public Notice sizeTooSmall = Notice.chat("&4✘ &cThe parcel's contents do not fit in that size."); public Notice destinationFull = Notice.chat("&4✘ &cThat destination locker is full."); + public Notice statusLocked = Notice.chat("&4✘ &cA collected parcel's status cannot be changed; it can only be returned."); public Notice contentsUpdated = Notice.chat("&2✔ &aParcel contents updated."); public Notice priorityUpdated = Notice.chat("&2✔ &aPriority updated and delivery time adjusted."); public Notice noPermission = Notice.chat("&4✘ &cYou do not have permission to do that."); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java index 60fa2f476..38fe917ad 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java @@ -188,6 +188,7 @@ private void notifyResult(Player player, EditResult result) { case OK -> this.noticeService.create().notice(m -> m.admin.parcelUpdated).player(player.getUniqueId()).send(); case SIZE_TOO_SMALL -> this.noticeService.create().notice(m -> m.admin.sizeTooSmall).player(player.getUniqueId()).send(); case DESTINATION_FULL -> this.noticeService.create().notice(m -> m.admin.destinationFull).player(player.getUniqueId()).send(); + case PARCEL_COLLECTED -> this.noticeService.create().notice(m -> m.admin.statusLocked).player(player.getUniqueId()).send(); } } @@ -227,6 +228,11 @@ private static ParcelSize nextSize(ParcelSize size) { } private static ParcelStatus nextStatus(ParcelStatus status) { + if (status == ParcelStatus.COLLECTED) { + // A COLLECTED parcel cannot be toggled back to SENT/DELIVERED; AdminParcelService + // rejects the change too, so this only avoids offering the flip in the first place. + return ParcelStatus.COLLECTED; + } return status == ParcelStatus.SENT ? ParcelStatus.DELIVERED : ParcelStatus.SENT; } } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index 36b6e7418..fdb3d1539 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -62,7 +62,16 @@ public CompletableFuture changeDescription(Parcel parcel, String des return this.persist(withDescription(parcel, description)); } + /** + * A COLLECTED parcel's content row is the collection snapshot, not a live shipment; flipping + * its status (e.g. back to SENT) would arm a delivery and hand the receiver the same items + * a second time. Refuse the change outright — a return is the only supported transition out + * of COLLECTED. + */ public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { + if (parcel.status() == ParcelStatus.COLLECTED) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + } Parcel updated = withStatus(parcel, status); return this.parcelService.update(updated).thenCompose(ignored -> { if (status == parcel.status()) { diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java index c245412e6..d00ef3add 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java @@ -2,7 +2,7 @@ public final class EditResult { - public enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL } + public enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL, PARCEL_COLLECTED } private final Status status; diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java index 586e18c27..aa33f5a2f 100644 --- a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -2,13 +2,34 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import java.time.Duration; import java.time.Instant; +import java.util.UUID; import org.junit.jupiter.api.Test; class AdminParcelServiceTest { + private static Parcel collectedParcel() { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "name", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED); + } + + @Test + void changeStatusRefusesCollectedParcelWithoutTouchingAnyCollaborator() { + // No collaborator is a usable mock/instance here on purpose: the COLLECTED guard must + // short-circuit before dereferencing any of them, so a regression that removes the guard + // and reaches a collaborator call would fail this test with an NPE instead of passing. + AdminParcelService service = new AdminParcelService(null, null, null, null, null, null); + + Parcel collected = collectedParcel(); + EditResult result = service.changeStatus(collected, ParcelStatus.SENT).join(); + + assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); + } + @Test void capacityMatchesContentGuiUsableSlots() { assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); From 9f97f09ba5c0d91c7d49fd1971cdbb6e105bd502 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:03:22 +0200 Subject: [PATCH 14/29] fix: contain post-commit failures in ParcelReturnService.execute (GH-69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-commit block after markReturned succeeded relied on each async step's own .exceptionally for safety, but a synchronous throw inside it (e.g. the scheduler rejecting work during plugin disable) would propagate to the outer exceptionally, which refunds the fee and gives the deposited items back even though the parcel is already a live SENT shipment whose content row holds those same items — duplicating them. Wrap the post-commit statements in a try/catch that only logs, so no synchronous exception past the commit point can ever reach the refund/give-back recovery path. Pre-commit behavior (refund + give-back + cannotReturn on a failed markReturned) is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UWGThsFyex5LiEyN63L3Wu --- .../returns/ParcelReturnService.java | 71 ++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index cb9e82b4d..1b16ab9cd 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -182,40 +182,47 @@ private CompletableFuture execute(Player player, Parcel current, List { - LOGGER.warning("Failed to delete collected_parcels row for returned parcel " - + current.uuid() + ": " + throwable.getMessage()); - return false; - }); - - Duration delay = returned.priority() - ? this.config.settings.priorityParcelSendDuration - : this.config.settings.parcelSendDuration; - - // Schedule the send task before persisting the delivery: ParcelSendTask.decide treats - // a missing delivery row as "deliver when due", so the task is safe even if the - // delivery upsert below fails. - this.scheduler.runLaterAsync( - new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), - delay); - - // Use update (upsert), not create: a stale delivery entry can still be cached for - // this UUID from the parcel's original trip (its cleanup delete is best-effort and - // re-cached on restart via cacheAll), and create() throws IllegalStateException on an - // existing cache entry. Throwing here must not happen post-commit. - this.deliveryManager.update(returned.uuid(), Instant.now().plus(delay)) - .exceptionally(throwable -> { - LOGGER.severe("Failed to persist delivery for returned parcel " + returned.uuid() - + " (send task scheduled in-memory; a restart before it fires may strand the parcel): " - + throwable.getMessage()); - return null; + // here would duplicate the items. The try/catch below makes that structural: even a + // synchronous throw (e.g. the scheduler rejecting work during plugin disable) is only + // logged, never allowed to reach the outer exceptionally. + try { + // Best-effort cleanup: a leftover row is ignored by the purge task because the + // parcel is no longer COLLECTED. + this.collectedParcelRepository.delete(current.uuid()).exceptionally(throwable -> { + LOGGER.warning("Failed to delete collected_parcels row for returned parcel " + + current.uuid() + ": " + throwable.getMessage()); + return false; }); - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + Duration delay = returned.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + + // Schedule the send task before persisting the delivery: ParcelSendTask.decide treats + // a missing delivery row as "deliver when due", so the task is safe even if the + // delivery upsert below fails. + this.scheduler.runLaterAsync( + new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), + delay); + + // Use update (upsert), not create: a stale delivery entry can still be cached for + // this UUID from the parcel's original trip (its cleanup delete is best-effort and + // re-cached on restart via cacheAll), and create() throws IllegalStateException on an + // existing cache entry. Throwing here must not happen post-commit. + this.deliveryManager.update(returned.uuid(), Instant.now().plus(delay)) + .exceptionally(throwable -> { + LOGGER.severe("Failed to persist delivery for returned parcel " + returned.uuid() + + " (send task scheduled in-memory; a restart before it fires may strand the parcel): " + + throwable.getMessage()); + return null; + }); + + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + } catch (Exception exception) { + LOGGER.severe("Post-commit step threw for returned parcel " + current.uuid() + + ": the return is already committed (parcel is SENT, content holds the deposited " + + "items) so no refund/give-back recovery was attempted: " + exception.getMessage()); + } return CompletableFuture.completedFuture(null); }) .exceptionally(throwable -> { From 6d86d468d89af8e895097ba6646e460d0cc60a9a Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:11:10 +0200 Subject: [PATCH 15/29] Add Graphify to CLAUDE.md --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0a7b1a572..071edc2d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,3 +59,13 @@ Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, ` ### Testing Tests live in `src/test/java/`. Integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `IntegrationTestSpec` and use Testcontainers (MySQL) to test repository implementations against a real database. `ParcelPageTest` is a unit test with no container dependency. + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). From fb79943cda9e00d23ae45e00ff8f7e50732e8512 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:59:00 +0200 Subject: [PATCH 16/29] fix: address GH-233 follow-up cleanups from GH-69 review Correctness hardening: - Make AdminParcelService.changeStatus a conditional UPDATE (ParcelRepository.updateIfStatus, WHERE status = expected) so a stale admin-GUI snapshot can no longer resurrect a parcel that concurrently became COLLECTED. - Upsert (not insert-if-absent) the collected_parcels row on collect, so a re-collect after a failed best-effort delete overwrites the stale collectedAt instead of shortening the new return window. Cleanup / UX: - ReturnWindowPurgeTask now also deletes the delivery row for a purged parcel, closing the leak left by a failed delete at ParcelSendTask.deliver time. - Move the returnFeeWithdrawn notice to after markReturned commits, so a lost race (fee refunded) no longer leaves the player thinking the fee was kept. - Reword insufficientFunds to be fee-neutral; it fires for both send and return fees. - Drop the dead result == null check in ReturnGui.show (getReturnableParcels never completes with null). Tests / docs / plumbing: - Add name/lore relaxed-flag coverage to ReturnItemEquivalenceTest. - Document the transitive-equivalence assumption ParcelReturnValidator's grouping relies on. - Switch new-code exception logging to LOGGER.log(Level, msg, throwable) so stack traces aren't dropped. - Move returnWindow onto GuiManager (alongside the existing allowCollectingFromAnyLocker pattern) instead of threading it through LockerGui/SendingGui/ItemStorageGui as a pass-through constructor param. Co-Authored-By: Claude Sonnet 5 --- .../parcellockers/ParcelLockers.java | 8 ++-- .../implementation/MessageConfig.java | 2 +- .../parcellockers/gui/GuiManager.java | 11 +++++- .../implementation/locker/ItemStorageGui.java | 9 +---- .../gui/implementation/locker/LockerGui.java | 11 ++---- .../gui/implementation/locker/ReturnGui.java | 9 ++--- .../gui/implementation/locker/SendingGui.java | 12 ++---- .../parcel/repository/ParcelRepository.java | 9 +++++ .../repository/ParcelRepositoryOrmLite.java | 27 +++++++++++++ .../parcel/service/AdminParcelService.java | 7 +++- .../parcel/service/ParcelService.java | 7 ++++ .../parcel/service/ParcelServiceImpl.java | 14 +++++++ .../parcel/task/ParcelSendTask.java | 9 +++-- .../returns/ParcelReturnService.java | 35 ++++++++++------- .../returns/ParcelReturnValidator.java | 7 ++++ .../CollectedParcelRepositoryOrmLite.java | 7 +++- .../returns/task/ReturnWindowPurgeTask.java | 23 +++++++++-- .../ParcelRepositoryIntegrationTest.java | 16 ++++++++ .../returns/ReturnItemEquivalenceTest.java | 38 +++++++++++++++++++ 19 files changed, 200 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 5b32696fc..184d3e1e2 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -175,7 +175,7 @@ public void onEnable() { ); scheduler.timerAsync( - new ReturnWindowPurgeTask(parcelService, collectedParcelRepository, config), + new ReturnWindowPurgeTask(parcelService, collectedParcelRepository, deliveryManager, config), Duration.ofSeconds(30), Duration.ofMinutes(30) ); @@ -191,7 +191,8 @@ public void onEnable() { parcelContentManager, deliveryManager, config.settings.allowCollectingFromAnyLocker, - parcelReturnService + parcelReturnService, + config.settings.parcelReturnWindow ); MainGui mainGUI = new MainGui( @@ -206,8 +207,7 @@ public void onEnable() { scheduler, config.guiSettings, guiManager, - noticeService, - config.settings.parcelReturnWindow + noticeService ); AdminParcelService adminParcelService = new AdminParcelService( diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index 06f9ed6bc..87cda1f19 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -118,7 +118,7 @@ public static class ParcelMessages extends OkaeriConfig { .sound(SoundEventKeys.ENTITY_ITEM_BREAK) .build(); public Notice insufficientFunds = Notice.builder() - .chat("&4✘ &cYou do not have enough funds to send this parcel! Required: &6${AMOUNT}&c.") + .chat("&4✘ &cYou do not have enough funds to cover this fee! Required: &6${AMOUNT}&c.") .sound(SoundEventKeys.ENTITY_VILLAGER_NO) .build(); public Notice feeWithdrawn = Notice.builder() diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 0c560a8e2..54666dc98 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -18,6 +18,7 @@ import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.user.User; import com.eternalcode.parcellockers.user.UserManager; +import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -38,6 +39,7 @@ public class GuiManager { private final DeliveryManager deliveryManager; private final boolean allowCollectingFromAnyLocker; private final ParcelReturnService parcelReturnService; + private final Duration returnWindow; public GuiManager( ParcelService parcelService, @@ -48,7 +50,8 @@ public GuiManager( ParcelContentManager parcelContentManager, DeliveryManager deliveryManager, boolean allowCollectingFromAnyLocker, - ParcelReturnService parcelReturnService + ParcelReturnService parcelReturnService, + Duration returnWindow ) { this.parcelService = parcelService; this.lockerManager = lockerManager; @@ -59,6 +62,12 @@ public GuiManager( this.deliveryManager = deliveryManager; this.allowCollectingFromAnyLocker = allowCollectingFromAnyLocker; this.parcelReturnService = parcelReturnService; + this.returnWindow = returnWindow; + } + + /** The configured return window, exposed so GUIs don't each need their own copy threaded in. */ + public Duration returnWindow() { + return this.returnWindow; } /** diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java index a7aef9406..0dc1d6eab 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java @@ -11,7 +11,6 @@ import dev.triumphteam.gui.guis.Gui; import dev.triumphteam.gui.guis.GuiItem; import dev.triumphteam.gui.guis.StorageGui; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; @@ -27,7 +26,6 @@ public class ItemStorageGui { private final GuiManager guiManager; private final NoticeService noticeService; private final SendingGuiState state; - private final Duration returnWindow; public ItemStorageGui( Scheduler scheduler, @@ -35,8 +33,7 @@ public ItemStorageGui( MiniMessage miniMessage, GuiManager guiManager, NoticeService noticeService, - SendingGuiState state, - Duration returnWindow + SendingGuiState state ) { this.scheduler = scheduler; this.guiSettings = guiSettings; @@ -44,7 +41,6 @@ public ItemStorageGui( this.guiManager = guiManager; this.noticeService = noticeService; this.state = state; - this.returnWindow = returnWindow; } void show(Player player, ParcelSize size) { @@ -115,8 +111,7 @@ void show(Player player, ParcelSize size) { this.miniMessage, this.noticeService, this.guiManager, - this.state, - this.returnWindow + this.state ).show(player)) .exceptionally(throwable -> { // Persisting the staged items failed; hand them back so they are not lost. diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java index ff90417e3..c5338bc6f 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java @@ -9,7 +9,6 @@ import com.eternalcode.parcellockers.notification.NoticeService; import dev.triumphteam.gui.guis.Gui; import dev.triumphteam.gui.guis.GuiItem; -import java.time.Duration; import java.util.UUID; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -22,19 +21,17 @@ public class LockerGui implements GuiView { private final GuiSettings guiSettings; private final GuiManager guiManager; private final NoticeService noticeService; - private final Duration returnWindow; public LockerGui( MiniMessage miniMessage, Scheduler scheduler, GuiSettings guiSettings, GuiManager guiManager, - NoticeService noticeService, Duration returnWindow + NoticeService noticeService ) { this.miniMessage = miniMessage; this.scheduler = scheduler; this.guiSettings = guiSettings; this.guiManager = guiManager; this.noticeService = noticeService; - this.returnWindow = returnWindow; } public void show(Player player, UUID entryLocker) { @@ -61,8 +58,7 @@ public void show(Player player, UUID entryLocker) { this.scheduler, this.guiManager, this.miniMessage, - this.noticeService, - this.returnWindow + this.noticeService ); gui.setItem(21, this.guiSettings.parcelLockerCollectItem.toGuiItem(event -> collectionGui.show(player))); @@ -73,8 +69,7 @@ public void show(Player player, UUID entryLocker) { this.miniMessage, this.noticeService, this.guiManager, - new SendingGuiState().entryLocker(entryLocker), - this.returnWindow + new SendingGuiState().entryLocker(entryLocker) ).show(player, entryLocker))); gui.open(player); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java index cf250a5be..365bbac28 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java @@ -39,22 +39,19 @@ public class ReturnGui implements GuiView { private final GuiManager guiManager; private final MiniMessage miniMessage; private final NoticeService noticeService; - private final Duration returnWindow; public ReturnGui( GuiSettings guiSettings, Scheduler scheduler, GuiManager guiManager, MiniMessage miniMessage, - NoticeService noticeService, - Duration returnWindow + NoticeService noticeService ) { this.guiSettings = guiSettings; this.scheduler = scheduler; this.guiManager = guiManager; this.miniMessage = miniMessage; this.noticeService = noticeService; - this.returnWindow = returnWindow; } @Override @@ -76,7 +73,7 @@ public void show(Player player, Page page) { this.setupStaticItems(player, gui); this.guiManager.getReturnableParcels(player.getUniqueId(), page).thenAccept(result -> { - if (result == null || result.items().isEmpty()) { + if (result.items().isEmpty()) { gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); this.scheduler.run(() -> gui.open(player)); return; @@ -131,7 +128,7 @@ private CompletableFuture> createParcelItemAsync( CompletableFuture windowLineFuture = this.guiManager.getCollectedInfo(parcel.uuid()) .thenApply(optional -> optional .map(collected -> { - Duration remaining = Duration.between(Instant.now(), collected.collectedAt().plus(this.returnWindow)); + Duration remaining = Duration.between(Instant.now(), collected.collectedAt().plus(this.guiManager.returnWindow())); return remaining.isNegative() || remaining.isZero() ? this.guiSettings.returnWindowExpiredLine : this.guiSettings.returnWindowRemainingLine.replace("{DURATION}", DurationUtil.format(remaining)); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java index dbb88960f..31b3d9f05 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java @@ -21,7 +21,6 @@ import io.papermc.paper.registry.data.dialog.action.DialogAction; import io.papermc.paper.registry.data.dialog.input.DialogInput; import io.papermc.paper.registry.data.dialog.type.DialogType; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -54,7 +53,6 @@ public class SendingGui implements GuiView { private final GuiManager guiManager; private final SendingGuiState state; - private final Duration returnWindow; private Gui gui; @@ -64,8 +62,7 @@ public SendingGui( MiniMessage miniMessage, NoticeService noticeService, GuiManager guiManager, - SendingGuiState state, - Duration returnWindow + SendingGuiState state ) { this.scheduler = scheduler; this.guiSettings = guiSettings; @@ -73,7 +70,6 @@ public SendingGui( this.noticeService = noticeService; this.guiManager = guiManager; this.state = state; - this.returnWindow = returnWindow; } public void show(Player player, UUID entryLocker) { @@ -196,8 +192,7 @@ public void show(Player player) { this.miniMessage, this.guiManager, this.noticeService, - this.state, - this.returnWindow + this.state ); this.guiManager.getItemStorage(player.getUniqueId()).thenAccept(result -> { int slotsSize = result.items().size(); @@ -256,8 +251,7 @@ public void show(Player player) { this.scheduler, this.guiSettings, this.guiManager, - this.noticeService, - this.returnWindow + this.noticeService ).show(player, this.state.entryLocker())); ConfigItem smallButton = this.guiSettings.smallParcelSizeItem; diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java index 1719c7688..f453a981b 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -1,6 +1,7 @@ package com.eternalcode.parcellockers.parcel.repository; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import java.util.List; @@ -15,6 +16,14 @@ public interface ParcelRepository { CompletableFuture update(Parcel parcel); + /** + * Conditionally persists every mutable field of {@code updated}, but only if the row's + * current status still equals {@code expectedStatus}. Returns false when the row moved out + * from under the caller (e.g. a concurrent collect), so a stale in-memory snapshot cannot + * clobber it. + */ + CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus); + CompletableFuture> findAll(); CompletableFuture> findPage(Page page); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java index adec6b8c4..eb4b2352b 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -22,6 +22,10 @@ public class ParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implement private static final String DESTINATION_LOCKER_COLUMN = "destination_locker"; private static final String ENTRY_LOCKER_COLUMN = "entry_locker"; private static final String STATUS_COLUMN = "status"; + private static final String NAME_COLUMN = "name"; + private static final String DESCRIPTION_COLUMN = "description"; + private static final String PRIORITY_COLUMN = "priority"; + private static final String SIZE_COLUMN = "size"; public ParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); @@ -40,6 +44,29 @@ public CompletableFuture update(Parcel parcel) { return this.upsert(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } + @Override + public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { + Objects.requireNonNull(updated, "Parcel cannot be null"); + Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); + return this.action(ParcelTable.class, dao -> { + UpdateBuilder builder = dao.updateBuilder(); + builder.updateColumnValue(SENDER_COLUMN, updated.sender()); + builder.updateColumnValue(NAME_COLUMN, updated.name()); + builder.updateColumnValue(DESCRIPTION_COLUMN, updated.description()); + builder.updateColumnValue(PRIORITY_COLUMN, updated.priority()); + builder.updateColumnValue(RECEIVER_COLUMN, updated.receiver()); + builder.updateColumnValue(SIZE_COLUMN, updated.size()); + builder.updateColumnValue(ENTRY_LOCKER_COLUMN, updated.entryLocker()); + builder.updateColumnValue(DESTINATION_LOCKER_COLUMN, updated.destinationLocker()); + builder.updateColumnValue(STATUS_COLUMN, updated.status()); + builder.where() + .eq(UUID_COLUMN, updated.uuid()) + .and() + .eq(STATUS_COLUMN, expectedStatus); + return builder.update() > 0; + }); + } + @Override public CompletableFuture> findById(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index fdb3d1539..01547f50b 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -73,7 +73,12 @@ public CompletableFuture changeStatus(Parcel parcel, ParcelStatus st return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); } Parcel updated = withStatus(parcel, status); - return this.parcelService.update(updated).thenCompose(ignored -> { + // Conditional on the snapshot's status: a concurrent collect between the GUI opening and + // this edit landing must not resurrect a COLLECTED parcel back to SENT/DELIVERED. + return this.parcelService.updateIfStatus(updated, parcel.status()).thenCompose(applied -> { + if (!Boolean.TRUE.equals(applied)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + } if (status == parcel.status()) { return CompletableFuture.completedFuture(EditResult.ok()); } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java index 364afc21f..581f2cdc4 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -2,6 +2,7 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import java.util.List; @@ -24,6 +25,12 @@ public interface ParcelService { CompletableFuture update(Parcel parcel); + /** + * Conditionally applies {@code updated}, refusing when the parcel's current status no + * longer matches {@code expectedStatus} (e.g. a concurrent collect raced an admin edit). + */ + CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus); + CompletableFuture collect(Player player, Parcel parcel); CompletableFuture delete(CommandSender sender, Parcel parcel); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index 4259aea10..a3bbbe472 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -181,6 +181,20 @@ public CompletableFuture update(Parcel updated) { return this.parcelRepository.update(updated); } + @Override + public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { + Objects.requireNonNull(updated, "Updated parcel cannot be null"); + Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); + return this.parcelRepository.updateIfStatus(updated, expectedStatus).thenApply(applied -> { + if (applied) { + this.parcelsByUuid.put(updated.uuid(), updated); + } else { + this.parcelsByUuid.invalidate(updated.uuid()); + } + return applied; + }); + } + @Override public CompletableFuture delete(CommandSender sender, Parcel parcel) { Objects.requireNonNull(sender, "Sender cannot be null"); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index 8c93901ce..d06f9d757 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.Optional; import java.util.UUID; +import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitRunnable; @@ -54,7 +55,7 @@ public void run() { // Parcel gone or already delivered: clean up any stray delivery row. optionalDelivery.ifPresent(delivery -> this.deliveryManager.delete(this.parcelId).exceptionally(throwable -> { - LOGGER.severe("Failed to delete stray delivery for " + this.parcelId + ": " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to delete stray delivery for " + this.parcelId, throwable); return false; })); case RESCHEDULE -> { @@ -67,7 +68,7 @@ public void run() { case DELIVER -> this.deliver(optionalParcel.get()); } })).exceptionally(throwable -> { - LOGGER.severe("ParcelSendTask failed for " + this.parcelId + ": " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "ParcelSendTask failed for " + this.parcelId, throwable); return null; }); } @@ -87,8 +88,8 @@ private void deliver(Parcel current) { this.parcelService.update(delivered) .thenCompose(ignored -> this.deliveryManager.delete(delivered.uuid())) .exceptionally(throwable -> { - LOGGER.severe("Failed to deliver parcel " + delivered.uuid() - + " (delivery left for retry): " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to deliver parcel " + delivered.uuid() + + " (delivery left for retry)", throwable); return null; }); } diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index 1b16ab9cd..052af49e6 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -23,6 +23,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; import java.util.logging.Logger; import net.milkbowl.vault.economy.Economy; import org.bukkit.Server; @@ -130,7 +131,7 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List { - LOGGER.severe("Failed to return parcel " + parcel.uuid() + " for " + player.getName() + ": " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to return parcel " + parcel.uuid() + " for " + player.getName(), throwable); this.giveBack(player, deposited); this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotReturn); return null; @@ -154,11 +155,9 @@ private CompletableFuture execute(Player player, Parcel current, List messages.parcel.returnFeeWithdrawn) - .player(player.getUniqueId()) - .placeholder(PLACEHOLDER_AMOUNT, formattedFee) - .send(); + // The fee-withdrawn notice is sent only after the status flip commits (below): if + // markReturned loses the race and the fee is refunded, the player must not have + // already been told the fee was withdrawn. } } double refundableFee = chargedFee; @@ -186,11 +185,19 @@ private CompletableFuture execute(Player player, Parcel current, List 0) { + this.noticeService.create() + .notice(messages -> messages.parcel.returnFeeWithdrawn) + .player(player.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, String.format("%.2f", refundableFee)) + .send(); + } + // Best-effort cleanup: a leftover row is ignored by the purge task because the // parcel is no longer COLLECTED. this.collectedParcelRepository.delete(current.uuid()).exceptionally(throwable -> { - LOGGER.warning("Failed to delete collected_parcels row for returned parcel " - + current.uuid() + ": " + throwable.getMessage()); + LOGGER.log(Level.WARNING, "Failed to delete collected_parcels row for returned parcel " + + current.uuid(), throwable); return false; }); @@ -211,17 +218,17 @@ private CompletableFuture execute(Player player, Parcel current, List { - LOGGER.severe("Failed to persist delivery for returned parcel " + returned.uuid() - + " (send task scheduled in-memory; a restart before it fires may strand the parcel): " - + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to persist delivery for returned parcel " + returned.uuid() + + " (send task scheduled in-memory; a restart before it fires may strand the parcel)", + throwable); return null; }); this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); } catch (Exception exception) { - LOGGER.severe("Post-commit step threw for returned parcel " + current.uuid() + LOGGER.log(Level.SEVERE, "Post-commit step threw for returned parcel " + current.uuid() + ": the return is already committed (parcel is SENT, content holds the deposited " - + "items) so no refund/give-back recovery was attempted: " + exception.getMessage()); + + "items) so no refund/give-back recovery was attempted", exception); } return CompletableFuture.completedFuture(null); }) @@ -229,7 +236,7 @@ private CompletableFuture execute(Player player, Parcel current, List messages.parcel.cannotReturn); - LOGGER.severe("Failed to execute return of parcel " + current.uuid() + ": " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to execute return of parcel " + current.uuid(), throwable); return null; }); } diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java index 89a63abe1..60bec55af 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java @@ -9,6 +9,13 @@ * Validates that deposited items are, as a multiset, exactly the original parcel content: * every deposited stack must be equivalent to some original item, and the total amount per * equivalence group must match. Stack splitting or merging is irrelevant. + * + *

Grouping ({@link #indexOf}) assumes {@code equivalence} is a true equivalence relation + * (reflexive, symmetric, transitive): each expected item is bucketed under the first + * sample it matches, and later items are folded into that same bucket without being re-compared + * to each other. If the predicate is not transitive (e.g. a "close enough" relaxed-attribute + * check where A~B and B~C but not A~C), distinct original items can collapse into one bucket + * and the amount check may pass for a return that should not have matched. */ public class ParcelReturnValidator { diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java index e27986fad..3e9ffe6c5 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java @@ -21,8 +21,11 @@ public CollectedParcelRepositoryOrmLite(DatabaseManager databaseManager, Schedul @Override public CompletableFuture save(CollectedParcel collectedParcel) { Objects.requireNonNull(collectedParcel, "CollectedParcel cannot be null"); - return this.insertIfAbsent(CollectedParcelTable.class, CollectedParcelTable.from(collectedParcel)) - .thenApply(dao -> null); + // Upsert, not insert-if-absent: a re-collect after a failed best-effort delete of the + // previous row must overwrite the stale collectedAt, not silently keep it (which would + // shorten the new return window by however long the parcel spent on its second trip). + return this.upsert(CollectedParcelTable.class, CollectedParcelTable.from(collectedParcel)) + .thenApply(status -> null); } @Override diff --git a/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java b/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java index 56281fe7c..46d98e3f4 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java @@ -1,6 +1,7 @@ package com.eternalcode.parcellockers.returns.task; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.returns.CollectedParcel; @@ -8,6 +9,7 @@ import java.time.Duration; import java.time.Instant; import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; import java.util.logging.Logger; /** @@ -23,15 +25,18 @@ public class ReturnWindowPurgeTask implements Runnable { private final ParcelService parcelService; private final CollectedParcelRepository collectedParcelRepository; + private final DeliveryManager deliveryManager; private final PluginConfig config; public ReturnWindowPurgeTask( ParcelService parcelService, CollectedParcelRepository collectedParcelRepository, + DeliveryManager deliveryManager, PluginConfig config ) { this.parcelService = parcelService; this.collectedParcelRepository = collectedParcelRepository; + this.deliveryManager = deliveryManager; this.config = config; } @@ -42,7 +47,7 @@ public void run() { this.collectedParcelRepository.findExpired(cutoff) .thenAccept(expired -> expired.forEach(this::purge)) .exceptionally(throwable -> { - LOGGER.severe("Failed to query expired collected parcels: " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to query expired collected parcels", throwable); return null; }); } @@ -56,14 +61,24 @@ private void purge(CollectedParcel collected) { return this.parcelService.delete(collected.parcel()) .thenCompose(deleted -> Boolean.TRUE.equals(deleted) ? this.collectedParcelRepository.delete(collected.parcel()) - : CompletableFuture.completedFuture(false)); + : CompletableFuture.completedFuture(false)) + .thenCompose(unused -> { + // Best-effort: clears a stray delivery row left behind by a failed + // delete at ParcelSendTask.deliver time; inert but leaks otherwise. + return this.deliveryManager.delete(collected.parcel()) + .exceptionally(throwable -> { + LOGGER.log(Level.WARNING, "Failed to delete stray delivery for purged parcel " + + collected.parcel(), throwable); + return false; + }); + }); } // Stray row: the parcel is gone or is a live shipment again — drop only the row. return this.collectedParcelRepository.delete(collected.parcel()); }) .exceptionally(throwable -> { - LOGGER.warning("Failed to purge expired collected parcel " + collected.parcel() - + " (will retry next run): " + throwable.getMessage()); + LOGGER.log(Level.WARNING, "Failed to purge expired collected parcel " + collected.parcel() + + " (will retry next run)", throwable); return false; }); } diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java index 4ee9ea80b..4892af966 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java @@ -1,6 +1,7 @@ package com.eternalcode.parcellockers.database; 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 com.eternalcode.parcellockers.TestScheduler; @@ -64,6 +65,21 @@ void test() throws SQLException { assertTrue(parcel.isPresent()); assertEquals(uuid, parcel.get().uuid()); + // updateIfStatus refuses when the expected status no longer matches the row (e.g. a + // concurrent status change won the race) and leaves the row untouched. + Parcel staleEdit = new Parcel(uuid, sender, "renamed", "description", true, receiver, + ParcelSize.SMALL, entryLocker, destinationLocker, ParcelStatus.DELIVERED); + boolean staleApplied = this.await(parcelRepository.updateIfStatus(staleEdit, ParcelStatus.DELIVERED)); + assertFalse(staleApplied); + assertEquals("name", this.await(parcelRepository.findById(uuid)).orElseThrow().name()); + + // updateIfStatus applies when the expected status still matches. + Parcel freshEdit = new Parcel(uuid, sender, "renamed", "description", true, receiver, + ParcelSize.SMALL, entryLocker, destinationLocker, ParcelStatus.SENT); + boolean freshApplied = this.await(parcelRepository.updateIfStatus(freshEdit, ParcelStatus.SENT)); + assertTrue(freshApplied); + assertEquals("renamed", this.await(parcelRepository.findById(uuid)).orElseThrow().name()); + List byReceiver = this.await(parcelRepository.findByReceiver(receiver)); assertEquals(1, byReceiver.size()); assertEquals(uuid, byReceiver.getFirst().uuid()); diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java index fcb2529fd..401d810e9 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java @@ -7,7 +7,9 @@ import static org.mockito.Mockito.withSettings; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.List; import java.util.Map; +import net.kyori.adventure.text.Component; import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemStack; @@ -101,6 +103,42 @@ private static Map enchantsOf(String key, int level) { return (Map) Map.of(key, level); } + @Test + void nameMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + + ItemMeta expectedMeta = mock(ItemMeta.class); + ItemMeta actualMeta = mock(ItemMeta.class); + when(expectedMeta.displayName()).thenReturn(Component.text("Old Sword")); + when(actualMeta.displayName()).thenReturn(Component.text("New Sword")); + when(expectedMeta.getEnchants()).thenReturn(Map.of()); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(false, true, false, false, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + @Test + void loreMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + + ItemMeta expectedMeta = mock(ItemMeta.class); + ItemMeta actualMeta = mock(ItemMeta.class); + when(expectedMeta.lore()).thenReturn(List.of(Component.text("Enchanted"))); + when(actualMeta.lore()).thenReturn(List.of()); + when(expectedMeta.getEnchants()).thenReturn(Map.of()); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(false, false, false, true, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + @Test void missingMetaOnBothSidesMatches() { ItemStack expected = item(Material.COBBLESTONE); From 01316805c7cb60a83b766a7d16d0f130ad2cc3f7 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:23:39 +0200 Subject: [PATCH 17/29] fix: run Vault economy calls on the primary thread in ParcelReturnService withdrawPlayer/depositPlayer were called from inside async CompletableFuture continuations chained off repository lookups, so they executed on a DB-executor thread rather than the main thread Vault providers expect. Dispatch both through Scheduler.complete/run to hop back onto the primary thread before touching Economy. Co-Authored-By: Claude Sonnet 5 --- .../returns/ParcelReturnService.java | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index 052af49e6..d22bad108 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -139,29 +139,33 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List execute(Player player, Parcel current, List deposited) { - double chargedFee = 0; - if (!player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { - double fee = this.returnFeeFor(current.size()); - if (fee > 0) { - boolean success = this.economy.withdrawPlayer(player, fee).transactionSuccess(); - String formattedFee = String.format("%.2f", fee); - if (!success) { + double fee = player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION) ? 0 : this.returnFeeFor(current.size()); + if (fee <= 0) { + return this.proceedWithReturn(player, current, deposited, 0); + } + + // Vault economy providers expect withdrawPlayer to run on the primary thread; by this + // point the call chain has already hopped onto a repository/DB executor thread, so the + // withdrawal must be scheduled back onto the main thread rather than called in place. + return this.scheduler.complete(() -> this.economy.withdrawPlayer(player, fee).transactionSuccess()) + .thenCompose(success -> { + if (!Boolean.TRUE.equals(success)) { this.noticeService.create() .notice(messages -> messages.parcel.insufficientFunds) .player(player.getUniqueId()) - .placeholder(PLACEHOLDER_AMOUNT, formattedFee) + .placeholder(PLACEHOLDER_AMOUNT, String.format("%.2f", fee)) .send(); this.giveBack(player, deposited); - return CompletableFuture.completedFuture(null); + return CompletableFuture.completedFuture(null); } - chargedFee = fee; // The fee-withdrawn notice is sent only after the status flip commits (below): if // markReturned loses the race and the fee is refunded, the player must not have // already been told the fee was withdrawn. - } - } - double refundableFee = chargedFee; + return this.proceedWithReturn(player, current, deposited, fee); + }); + } + private CompletableFuture proceedWithReturn(Player player, Parcel current, List deposited, double refundableFee) { Parcel returned = new Parcel(current.uuid(), current.receiver(), current.name(), current.description(), current.priority(), current.sender(), current.size(), current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); @@ -251,7 +255,9 @@ private double returnFeeFor(ParcelSize size) { private void refund(Player player, double fee) { if (fee > 0) { - this.economy.depositPlayer(player, fee); + // Same primary-thread requirement as the withdrawal in execute(): this runs from + // async continuations, so the deposit must be dispatched back onto the main thread. + this.scheduler.run(() -> this.economy.depositPlayer(player, fee)); } } From 87f5841321224337afda12ecc6bc77539ea7920c Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:04:03 +0200 Subject: [PATCH 18/29] fix: make parcel returns atomic and thread-safe - commit parcel status, content, delivery, and collection changes atomically - reject returns when the original locker no longer exists - wait for durable delivery persistence before reporting success - move GUI operations and player permission access onto the main thread - invalidate affected caches after a successful return - remove the obsolete non-atomic markReturned API - add concurrency, rollback, service, and GUI-thread regression tests --- .gitignore | 5 +- build.gradle.kts | 8 + .../2026-07-14-parcel-return-review-fixes.md | 292 ++++++++++++++++++ ...07-14-parcel-return-review-fixes-design.md | 101 ++++++ .../parcellockers/ParcelLockers.java | 4 + .../content/ParcelContentManager.java | 4 + .../repository/ParcelContentTable.java | 4 +- .../delivery/DeliveryManager.java | 4 + .../delivery/repository/DeliveryTable.java | 2 +- .../gui/implementation/locker/ReturnGui.java | 57 ++-- .../parcel/repository/ParcelRepository.java | 7 - .../repository/ParcelRepositoryOrmLite.java | 18 -- .../parcel/repository/ParcelTable.java | 2 +- .../parcel/service/ParcelService.java | 8 +- .../parcel/service/ParcelServiceImpl.java | 18 +- .../returns/ParcelReturnService.java | 139 ++++----- .../repository/CollectedParcelTable.java | 2 +- .../repository/ParcelReturnRepository.java | 16 + .../ParcelReturnRepositoryOrmLite.java | 61 ++++ ...ReturnCommitRepositoryIntegrationTest.java | 189 ++++++++++++ ...ParcelReturnRepositoryIntegrationTest.java | 23 -- .../implementation/locker/ReturnGuiTest.java | 85 +++++ .../returns/ParcelReturnServiceTest.java | 148 +++++++++ 23 files changed, 1036 insertions(+), 161 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md create mode 100644 docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java create mode 100644 src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java create mode 100644 src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java diff --git a/.gitignore b/.gitignore index ccd8aeb32..2fcfe81ad 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,12 @@ run/ out/ build/ +.claude/ +.codex/ +.gemini/ +graphify-out/ .DS_Store [Dd]esktop.ini !gradle/wrapper/gradle-wrapper.jar -.claude diff --git a/build.gradle.kts b/build.gradle.kts index 8f05d11c4..6b47905b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,8 +92,16 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter:${Versions.TESTCONTAINERS}") testImplementation("org.testcontainers:mysql:${Versions.TESTCONTAINERS}") + testImplementation("com.zaxxer:HikariCP:${Versions.HIKARICP}") + testImplementation("com.j256.ormlite:ormlite-jdbc:${Versions.ORMLITE}") + testImplementation("com.h2database:h2:${Versions.H2}") testImplementation("mysql:mysql-connector-java:${Versions.MYSQL_CONNECTOR}") + testImplementation("de.eldoria.jacksonbukkit:paper:${Versions.JACKSON_BUKKIT}") + testImplementation("com.eternalcode:eternalcode-commons-adventure:${Versions.ETERNALCODE_COMMONS}") testImplementation("com.eternalcode:eternalcode-commons-shared:${Versions.ETERNALCODE_COMMONS}") + testImplementation("com.eternalcode:multification-bukkit:${Versions.MULTIFICATION}") + testImplementation("com.github.MilkBowl:VaultAPI:${Versions.VAULT_API}") + testImplementation("dev.triumphteam:triumph-gui-paper:${Versions.TRIUMPH_GUI}") testImplementation("io.papermc.paper:paper-api:${Versions.PAPER_API}") testImplementation("eu.okaeri:okaeri-configs-yaml-bukkit:${Versions.OKAERI_CONFIGS}") diff --git a/docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md b/docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md new file mode 100644 index 000000000..8c1d74f1b --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md @@ -0,0 +1,292 @@ +# Parcel Return Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make parcel returns concurrency-safe, durably scheduled, locker-valid, and main-thread-safe. + +**Architecture:** A return-specific ORMLite repository commits the conditional parcel transition, deposited content, delivery row, and collected-row cleanup in one transaction. The service validates locker existence and captures Bukkit permission state before async work, invalidates affected caches after commit, and schedules only after persistence. Return GUI data remains asynchronous while all GUI mutation is dispatched through the primary-thread scheduler. + +**Tech Stack:** Java 21, Paper 1.21, ORMLite 6.1, CompletableFuture, Triumph GUI, JUnit 5, Mockito, Testcontainers MySQL. + +## Global Constraints + +- Preserve the existing parcel-return behavior and player-facing configuration. +- Add no dependencies or schema columns. +- Repository work remains asynchronous through the existing `Scheduler`. +- Bukkit player, inventory, event, and GUI APIs run on the primary thread. +- Use a failing regression test before each production behavior change. +- Docker-gated integration tests may skip only when Docker is unavailable; report that explicitly. +- Do not commit or modify unrelated user-owned files. + +--- + +### Task 1: Atomic Return Commit + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java` +- Test: `src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java` + +**Interfaces:** +- Consumes: `Parcel`, `ParcelContent`, `Delivery`, and `DatabaseManager.connectionSource()`. +- Produces: `CompletableFuture ParcelReturnRepository.commit(Parcel, ParcelContent, Delivery)`. + +- [ ] **Step 1: Write the failing concurrent-winner integration test** + +Create two return commits for the same `COLLECTED` parcel with different material content and delivery timestamps: + +```java +CompletableFuture first = repository.commit(returned, + new ParcelContent(parcel.uuid(), List.of(new ItemStack(Material.DIAMOND))), + new Delivery(parcel.uuid(), firstDelivery)); +CompletableFuture second = repository.commit(returned, + new ParcelContent(parcel.uuid(), List.of(new ItemStack(Material.EMERALD))), + new Delivery(parcel.uuid(), secondDelivery)); + +boolean firstWon = await(this.first); +boolean secondWon = await(this.second); +assertNotEquals(firstWon, secondWon); +Material expectedMaterial = this.firstWon ? Material.DIAMOND : Material.EMERALD; +Instant expectedDelivery = this.firstWon ? firstDelivery : secondDelivery; +assertEquals(expectedMaterial, await(contentRepository.find(parcel.uuid())) + .orElseThrow().items().getFirst().getType()); +assertEquals(expectedDelivery, await(deliveryRepository.find(parcel.uuid())) + .orElseThrow().deliveryTimestamp()); +assertEquals(ParcelStatus.SENT, await(parcelRepository.findById(parcel.uuid())) + .orElseThrow().status()); +assertTrue(await(collectedRepository.find(parcel.uuid())).isEmpty()); +``` + +- [ ] **Step 2: Run the new test to verify RED** + +Run: +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.database.ParcelReturnCommitRepositoryIntegrationTest" +``` + +Expected: compilation fails because `ParcelReturnRepository` does not exist. + +- [ ] **Step 3: Implement the transaction** + +Define: + +```java +public interface ParcelReturnRepository { + CompletableFuture commit(Parcel returned, ParcelContent content, Delivery delivery); +} +``` + +The ORMLite implementation runs on `action(ParcelTable.class, ...)` and calls: + +```java +return TransactionManager.callInTransaction(this.databaseManager.connectionSource(), () -> { + UpdateBuilder update = parcelDao.updateBuilder(); + update.updateColumnValue("sender", returned.sender()); + update.updateColumnValue("receiver", returned.receiver()); + update.updateColumnValue("entry_locker", returned.entryLocker()); + update.updateColumnValue("destination_locker", returned.destinationLocker()); + update.updateColumnValue("status", ParcelStatus.SENT); + update.where().eq("uuid", returned.uuid()).and().eq("status", ParcelStatus.COLLECTED); + if (update.update() == 0) { + return false; + } + this.databaseManager.getDao(ParcelContentTable.class) + .createOrUpdate(ParcelContentTable.from(content)); + this.databaseManager.getDao(DeliveryTable.class) + .createOrUpdate(DeliveryTable.from(delivery)); + this.databaseManager.getDao(CollectedParcelTable.class).deleteById(returned.uuid()); + return true; +}); +``` + +Expose only the table classes and `from(...)` factories required by the coordinator; keep constructors and domain conversion methods otherwise unchanged. + +- [ ] **Step 4: Run the integration test to verify GREEN** + +Run the command from Step 2. Expected: both atomicity tests pass when Docker is available; otherwise JUnit reports the Docker-gated class skipped. + +--- + +### Task 2: Use the Atomic Commit and Preserve Cache Correctness + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: `ParcelReturnRepository.commit(...)`. +- Produces: `void invalidate(UUID)` on parcel/content/delivery caches. + +- [ ] **Step 1: Write a failing service test for durable commit ordering** + +Mock `ParcelReturnRepository.commit` with an incomplete future, call `returnParcel`, and verify that neither `scheduler.runLaterAsync` nor the returned-success notice occurs until that future completes `true`. Complete it `false` in a second test and verify the deposited items are returned and no task is scheduled. + +- [ ] **Step 2: Run the focused service test to verify RED** + +Run: +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" +``` + +Expected: compilation fails because the service has no return repository dependency or cache invalidation API. + +- [ ] **Step 3: Implement commit-first scheduling** + +Prepare one delivery timestamp, then: + +```java +ParcelContent content = new ParcelContent(returned.uuid(), depositedCopy); +Delivery delivery = new Delivery(returned.uuid(), Instant.now().plus(delay)); +return this.parcelReturnRepository.commit(returned, content, delivery).thenCompose(committed -> { + if (!committed) { + this.refund(player, refundableFee); + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + this.parcelService.invalidate(returned.uuid()); + this.parcelContentManager.invalidate(returned.uuid()); + this.deliveryManager.invalidate(returned.uuid()); + this.scheduler.runLaterAsync( + new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), delay); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + return CompletableFuture.completedFuture(null); +}); +``` + +Remove the pre-claim content update, separate `markReturned`, fire-and-forget delivery update, and best-effort collected-row delete from this flow. Wire `ParcelReturnRepositoryOrmLite` in `ParcelLockers.onEnable`. + +- [ ] **Step 4: Run the focused service test to verify GREEN** + +Run the command from Step 2. Expected: all service tests pass. + +--- + +### Task 3: Validate Locker Existence and Capture Permission on Main Thread + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: existing `LockerManager.get(UUID)` and `LockerManager.isLockerFull(UUID)`. +- Produces: no new public API. + +- [ ] **Step 1: Write failing missing-locker and permission-timing tests** + +For the missing-locker test, return `Optional.empty()` from `lockerManager.get(entryLocker)`, then verify `isLockerFull`, economy withdrawal, and repository commit are never called. For permission timing, leave `parcelService.get` incomplete and verify `player.hasPermission("parcellockers.fee.bypass")` was already called immediately after `returnParcel`. + +- [ ] **Step 2: Run the focused service test to verify RED** + +Run the Task 2 focused test command. Expected: missing-locker verification fails because `get` is not called, and permission verification fails because it is currently delayed until an async continuation. + +- [ ] **Step 3: Implement the main-thread and existence checks** + +Capture: + +```java +boolean feeBypassed = player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION); +``` + +before the first repository future, pass it into `execute`, and replace the direct fullness call with: + +```java +return this.lockerManager.get(current.entryLocker()).thenCompose(locker -> { + if (locker.isEmpty()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { + if (Boolean.TRUE.equals(isFull)) { + return this.abort(player, deposited, messages -> messages.parcel.lockerFull); + } + return this.execute(player, current, deposited, feeBypassed); + }); +}); +``` + +- [ ] **Step 4: Run the focused service test to verify GREEN** + +Run the Task 2 focused test command. Expected: all service tests pass. + +--- + +### Task 4: Confine Return GUI Mutation to the Primary Thread + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java` +- Create: `src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java` + +**Interfaces:** +- Consumes: `Scheduler.run(Runnable)`. +- Produces: a package-private constructor accepting `Function` for test-controlled GUI creation. + +- [ ] **Step 1: Write the failing empty-result threading test** + +Construct `ReturnGui` with a mocked `PaginatedGui), an incomplete returnable-parcels future, and a mocked scheduler that captures but does not run callbacks. After `show`, clear initial synchronous setup interactions, complete the future with an empty page, verify no new GUI mutation occurred, run the captured callback, then verify the empty item was set and the GUI opened. + +- [ ] **Step 2: Run the GUI test to verify RED** + +Run: +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnGuiTest" +``` + +Expected: the verification fails because the current async continuation calls `gui.setItem` before scheduling. + +- [ ] **Step 3: Implement a single scheduled render boundary** + +Keep data futures asynchronous. Move empty-state item placement, navigation setup, `PaginatedGuiRefresher` construction/population, and `gui.open` into `scheduler.run(() -> { ... })`. The public constructor delegates to the package-private factory constructor: + +```java +this(guiSettings, scheduler, guiManager, miniMessage, noticeService, + title -> Gui.paginated().rows(6).disableAllInteractions().title(title).create()); +``` + +- [ ] **Step 4: Run the GUI test to verify GREEN** + +Run the command from Step 2. Expected: the test passes. + +--- + +### Task 5: Full Verification + +**Files:** +- Review all files changed by Tasks 1-4. + +**Interfaces:** +- Consumes: completed fixes. +- Produces: fresh test/build evidence and a clean patch check. + +- [ ] **Step 1: Run focused return and GUI tests** + +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.*" --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnGuiTest" --tests "com.eternalcode.parcellockers.database.ParcelReturnCommitRepositoryIntegrationTest" +``` + +- [ ] **Step 2: Run the full suite** + +```powershell +.\gradlew.bat test +``` + +- [ ] **Step 3: Build the plugin JAR** + +```powershell +.\gradlew.bat shadowJar +``` + +- [ ] **Step 4: Inspect the final patch** + +```powershell +git diff --check +git status --short +``` + +Expected: commands exit successfully; report any Docker-skipped Testcontainers tests rather than describing them as executed. diff --git a/docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md b/docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md new file mode 100644 index 000000000..abd60b725 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md @@ -0,0 +1,101 @@ +# Parcel Return Review Fixes — Design + +Date: 2026-07-14 +Status: Approved in conversation +Review: `PR_REVIEW_feat-parcel-return-gh-69.md` + +## Summary + +The parcel-return flow needs four corrections before merge: atomic winner-owned +content, durable delivery scheduling, destination-locker validation, and safe +main-thread boundaries for Bukkit and GUI APIs. + +## Accepted findings + +All four findings are valid for the current branch: + +1. Content replacement happens before the conditional return claim, so a losing + concurrent attempt can overwrite the winning shipment's content. +2. Locker capacity lookup does not prove that the original entry locker still + exists, so a returned parcel can be addressed to a deleted locker. +3. The parcel becomes `SENT` before its delivery row is durably persisted, so a + restart can leave it without anything startup recovery can schedule. +4. Repository futures complete on async scheduler threads, but `ReturnGui` mutates + GUI state and `ParcelReturnService` checks player permissions in continuations. + +## Architecture + +### Atomic return commit + +Introduce a return-specific repository operation backed by ORMLite's transaction +support. It accepts the prepared reverse `Parcel`, deposited `ParcelContent`, and +`Delivery`, then performs these operations on one database connection and in one +transaction: + +1. Conditionally update the parcel only when its current status is `COLLECTED`. +2. If no row was updated, return `false` without changing content or delivery data. +3. Upsert the deposited parcel content. +4. Upsert the delivery timestamp. +5. Delete the corresponding `collected_parcels` row. +6. Commit and return `true`. + +Any exception rolls back every operation. Two concurrent attempts therefore have +one winner, and the content and delivery row always belong to that winner. The +transaction also makes every committed `SENT` return discoverable by startup +delivery recovery before the player is told it succeeded. + +After a successful commit, the service synchronizes the parcel, content, and +delivery caches without issuing additional database writes. It then schedules the +in-memory delivery task. If scheduling throws during shutdown, the durable delivery +row remains available for startup recovery; deposited items and fees are not +returned because the reverse shipment has already committed. + +### Locker validation + +Resolve the original entry locker with `LockerManager.get` before the fullness +query. An absent locker rejects the attempt using the generic cannot-return notice, +returns the deposited items, and does not charge a fee. Capacity is checked only for +an existing locker. + +### Main-thread boundary + +Capture the fee-bypass permission before entering repository continuations. The +return entry point is invoked by a GUI event on the primary thread, while Vault +withdrawal and refund operations remain explicitly scheduled there. + +`ReturnGui` continues fetching parcel/content data asynchronously, but every GUI +mutation is placed in one primary-thread callback: navigation setup, empty-state +item placement, `PaginatedGuiRefresher` population, and `gui.open`. Async work may +only prepare immutable/plain data and deferred item suppliers. + +## Error handling + +- A conditional-claim loss refunds any charged fee and gives the deposited items + back. +- A transaction failure rolls back all persistent return state, refunds the fee, + gives the deposited items back, and sends the generic failure notice. +- A post-commit scheduling failure is logged but never triggers refund/item + give-back, because doing so would duplicate committed shipment contents. +- A missing original locker is handled before charging or committing. + +## Testing + +- Add a Docker-gated integration test that launches two return commits with + different deposited content, asserts exactly one succeeds, and verifies parcel, + content, and delivery rows all belong to the winner. +- Add an integration test proving a failed conditional claim leaves existing + content and delivery data unchanged. +- Add focused service tests for missing original locker and for permission capture + before async repository completion where practical with existing mocks. +- Add a focused `ReturnGui` test or extract a small scheduling boundary that proves + GUI mutation is dispatched through the primary-thread scheduler. +- Run focused tests after each red/green cycle, then the complete Gradle test suite + and `shadowJar`. + +## Out of scope + +- Changing return fees, windows, item-equivalence rules, or GUI copy. +- Introducing an intermediate `RETURNING` parcel status. +- Solving cross-request locker-capacity reservation beyond the existing best-effort + fullness model. +- Refactoring unrelated send/collection persistence paths. diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 184d3e1e2..4eaea7479 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -48,6 +48,7 @@ import com.eternalcode.parcellockers.returns.ParcelReturnValidator; import com.eternalcode.parcellockers.returns.ReturnItemEquivalence; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepositoryOrmLite; import com.eternalcode.parcellockers.returns.task.ReturnWindowPurgeTask; import com.eternalcode.parcellockers.updater.UpdaterService; import com.eternalcode.parcellockers.user.UserManager; @@ -128,6 +129,8 @@ public void onEnable() { ItemStorageRepository itemStorageRepository = new ItemStorageRepositoryOrmLite(databaseManager, scheduler); UserRepository userRepository = new UserRepositoryOrmLite(databaseManager, scheduler); CollectedParcelRepositoryOrmLite collectedParcelRepository = new CollectedParcelRepositoryOrmLite(databaseManager, scheduler); + ParcelReturnRepositoryOrmLite parcelReturnRepository = + new ParcelReturnRepositoryOrmLite(databaseManager, scheduler); // service and managers ParcelService parcelService = new ParcelServiceImpl( @@ -167,6 +170,7 @@ public void onEnable() { deliveryManager, lockerManager, returnValidator, + parcelReturnRepository, scheduler, config, noticeService, diff --git a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java b/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java index 69cdc0533..4f477bd96 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java +++ b/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java @@ -66,6 +66,10 @@ public CompletableFuture delete(UUID parcel) { }); } + public void invalidate(UUID parcel) { + this.cache.invalidate(parcel); + } + public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { return this.contentRepository.deleteAll().thenAccept(deleted -> { noticeService.create() diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java index e99f0aa24..4ae909208 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java +++ b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java @@ -9,7 +9,7 @@ import org.bukkit.inventory.ItemStack; @DatabaseTable(tableName = "parcel_content") -class ParcelContentTable { +public class ParcelContentTable { @DatabaseField(id = true) private UUID uniqueId; @@ -25,7 +25,7 @@ class ParcelContentTable { this.content = content; } - static ParcelContentTable from(ParcelContent parcelContent) { + public static ParcelContentTable from(ParcelContent parcelContent) { return new ParcelContentTable(parcelContent.uniqueId(), parcelContent.items()); } diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java b/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java index d34ae7d70..265136f52 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java @@ -64,6 +64,10 @@ public CompletableFuture delete(UUID parcel) { }); } + public void invalidate(UUID parcel) { + this.deliveryCache.invalidate(parcel); + } + public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { return this.deliveryRepository.deleteAll().thenAccept(deleted -> { this.deliveryCache.invalidateAll(); diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java index 6fc570bc6..42fa18f29 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java @@ -8,7 +8,7 @@ import java.util.UUID; @DatabaseTable(tableName = "deliveries") -class DeliveryTable { +public class DeliveryTable { @DatabaseField(id = true) private UUID parcel; diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java index 365bbac28..4bfe02d19 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; import java.util.function.Supplier; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -39,6 +40,7 @@ public class ReturnGui implements GuiView { private final GuiManager guiManager; private final MiniMessage miniMessage; private final NoticeService noticeService; + private final Function guiFactory; public ReturnGui( GuiSettings guiSettings, @@ -46,12 +48,35 @@ public ReturnGui( GuiManager guiManager, MiniMessage miniMessage, NoticeService noticeService + ) { + this( + guiSettings, + scheduler, + guiManager, + miniMessage, + noticeService, + title -> Gui.paginated() + .rows(6) + .disableAllInteractions() + .title(title) + .create() + ); + } + + ReturnGui( + GuiSettings guiSettings, + Scheduler scheduler, + GuiManager guiManager, + MiniMessage miniMessage, + NoticeService noticeService, + Function guiFactory ) { this.guiSettings = guiSettings; this.scheduler = scheduler; this.guiManager = guiManager; this.miniMessage = miniMessage; this.noticeService = noticeService; + this.guiFactory = guiFactory; } @Override @@ -64,39 +89,35 @@ public void show(Player player, Page page) { Component guiTitle = this.miniMessage.deserialize(this.guiSettings.parcelReturnGuiTitle); ConfigItem rowItem = this.guiSettings.parcelReturnRowItem; - PaginatedGui gui = Gui.paginated() - .rows(6) - .disableAllInteractions() - .title(guiTitle) - .create(); + PaginatedGui gui = this.guiFactory.apply(guiTitle); this.setupStaticItems(player, gui); this.guiManager.getReturnableParcels(player.getUniqueId(), page).thenAccept(result -> { if (result.items().isEmpty()) { - gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); - this.scheduler.run(() -> gui.open(player)); + this.scheduler.run(() -> { + gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); + gui.open(player); + }); return; } - PaginatedGuiRefresher refresher = new PaginatedGuiRefresher(gui); - - this.setupNavigation(gui, page, result, player, this.guiSettings); - result.items().stream() .map(parcel -> this.createParcelItemAsync(parcel, rowItem, player)) .collect(CompletableFutures.joinList()) - .thenAccept(suppliers -> { + .thenAccept(suppliers -> this.scheduler.run(() -> { if (suppliers.isEmpty()) { gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); - this.scheduler.run(() -> gui.open(player)); + gui.open(player); return; } - for (Supplier supplier : suppliers) { - refresher.addItem(supplier); - } - this.scheduler.run(() -> gui.open(player)); - }).exceptionally(FutureHandler::handleException); + + this.setupNavigation(gui, page, result, player, this.guiSettings); + PaginatedGuiRefresher refresher = new PaginatedGuiRefresher(gui); + suppliers.forEach(refresher::addItem); + gui.open(player); + })) + .exceptionally(FutureHandler::handleException); }).exceptionally(FutureHandler::handleException); } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java index f453a981b..81713f214 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -58,13 +58,6 @@ public interface ParcelRepository { */ CompletableFuture markCollected(UUID uuid); - /** - * Atomically turns a COLLECTED parcel into its reverse SENT shipment (parties and lockers - * swapped as prepared by the caller). Returns false when the parcel is missing or not - * COLLECTED — the caller must treat that as "already returned or purged". - */ - CompletableFuture markReturned(Parcel returned); - /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ CompletableFuture> findReturnable(UUID receiver, Page page); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java index eb4b2352b..a459542da 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -140,24 +140,6 @@ public CompletableFuture markCollected(UUID uuid) { }); } - @Override - public CompletableFuture markReturned(Parcel returned) { - Objects.requireNonNull(returned, "Returned parcel cannot be null"); - return this.action(ParcelTable.class, dao -> { - UpdateBuilder builder = dao.updateBuilder(); - builder.updateColumnValue(SENDER_COLUMN, returned.sender()); - builder.updateColumnValue(RECEIVER_COLUMN, returned.receiver()); - builder.updateColumnValue(ENTRY_LOCKER_COLUMN, returned.entryLocker()); - builder.updateColumnValue(DESTINATION_LOCKER_COLUMN, returned.destinationLocker()); - builder.updateColumnValue(STATUS_COLUMN, ParcelStatus.SENT); - builder.where() - .eq(UUID_COLUMN, returned.uuid()) - .and() - .eq(STATUS_COLUMN, ParcelStatus.COLLECTED); - return builder.update() > 0; - }); - } - @Override public CompletableFuture> findReturnable(UUID receiver, Page page) { Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java index 005922116..9b13f42b6 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java @@ -8,7 +8,7 @@ import java.util.UUID; @DatabaseTable(tableName = "parcels") -class ParcelTable { +public class ParcelTable { @DatabaseField(columnName = "uuid", id = true) private UUID uuid; diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java index 581f2cdc4..26857b40c 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -39,6 +39,8 @@ public interface ParcelService { CompletableFuture> get(UUID uuid); + void invalidate(UUID uuid); + CompletableFuture> getBySender(UUID sender, Page page); CompletableFuture> getByReceiver(UUID receiver, Page page); @@ -54,12 +56,6 @@ public interface ParcelService { /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ CompletableFuture> getReturnable(UUID receiver, Page page); - /** - * Atomically turns a COLLECTED parcel into its reverse SENT shipment. Returns false when - * the parcel was already returned or purged in the meantime. - */ - CompletableFuture markReturned(Parcel returned); - CompletableFuture> getAll(Page page); CompletableFuture delete(UUID uuid); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index a3bbbe472..fbfe57780 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -301,6 +301,12 @@ public CompletableFuture> get(UUID uuid) { }); } + @Override + public void invalidate(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + this.parcelsByUuid.invalidate(uuid); + } + @Override public CompletableFuture> getBySender(UUID sender, Page page) { Objects.requireNonNull(sender, "Sender UUID cannot be null"); @@ -349,18 +355,6 @@ public CompletableFuture> getReturnable(UUID receiver, Page p }); } - @Override - public CompletableFuture markReturned(Parcel returned) { - Objects.requireNonNull(returned, "Returned parcel cannot be null"); - - return this.parcelRepository.markReturned(returned).thenApply(updated -> { - if (updated) { - this.parcelsByUuid.put(returned.uuid(), returned); - } - return updated; - }); - } - @Override public CompletableFuture> getAll(Page page) { Objects.requireNonNull(page, "Page cannot be null"); diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index d22bad108..e9fdbfc7b 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -5,7 +5,9 @@ import com.eternalcode.multification.notice.provider.NoticeProvider; import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.Delivery; import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.locker.LockerManager; import com.eternalcode.parcellockers.notification.NoticeService; @@ -16,6 +18,7 @@ import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -47,6 +50,7 @@ public class ParcelReturnService { private final DeliveryManager deliveryManager; private final LockerManager lockerManager; private final ParcelReturnValidator validator; + private final ParcelReturnRepository parcelReturnRepository; private final Scheduler scheduler; private final PluginConfig config; private final NoticeService noticeService; @@ -60,6 +64,7 @@ public ParcelReturnService( DeliveryManager deliveryManager, LockerManager lockerManager, ParcelReturnValidator validator, + ParcelReturnRepository parcelReturnRepository, Scheduler scheduler, PluginConfig config, NoticeService noticeService, @@ -72,6 +77,7 @@ public ParcelReturnService( this.deliveryManager = deliveryManager; this.lockerManager = lockerManager; this.validator = validator; + this.parcelReturnRepository = parcelReturnRepository; this.scheduler = scheduler; this.config = config; this.noticeService = noticeService; @@ -98,6 +104,7 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List messages.parcel.cannotReturn); } + boolean feeBypassed = player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION); return this.parcelService.get(parcel.uuid()).thenCompose(optionalParcel -> { if (optionalParcel.isEmpty() @@ -121,12 +128,18 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List messages.parcel.returnItemsMismatch); } - // The return ships to the original entry locker. - return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { - if (Boolean.TRUE.equals(isFull)) { - return this.abort(player, deposited, messages -> messages.parcel.lockerFull); + // The return ships to the original entry locker. It may have been deleted + // while the parcel was in the receiver's return window. + return this.lockerManager.get(current.entryLocker()).thenCompose(locker -> { + if (locker.isEmpty()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); } - return this.execute(player, current, deposited); + return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { + if (Boolean.TRUE.equals(isFull)) { + return this.abort(player, deposited, messages -> messages.parcel.lockerFull); + } + return this.execute(player, current, deposited, feeBypassed); + }); }); }); }); @@ -138,8 +151,13 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List execute(Player player, Parcel current, List deposited) { - double fee = player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION) ? 0 : this.returnFeeFor(current.size()); + private CompletableFuture execute( + Player player, + Parcel current, + List deposited, + boolean feeBypassed + ) { + double fee = feeBypassed ? 0 : this.returnFeeFor(current.size()); if (fee <= 0) { return this.proceedWithReturn(player, current, deposited, 0); } @@ -156,11 +174,11 @@ private CompletableFuture execute(Player player, Parcel current, ListcompletedFuture(null); + return CompletableFuture.completedFuture(null); } - // The fee-withdrawn notice is sent only after the status flip commits (below): if - // markReturned loses the race and the fee is refunded, the player must not have - // already been told the fee was withdrawn. + // The fee-withdrawn notice is sent only after the atomic return commit succeeds: + // if this attempt loses the race and the fee is refunded, the player must not + // already have been told the fee was withdrawn. return this.proceedWithReturn(player, current, deposited, fee); }); } @@ -171,71 +189,50 @@ private CompletableFuture proceedWithReturn(Player player, Parcel current, current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); List depositedCopy = deposited.stream().map(ItemStack::clone).toList(); + Duration delay = returned.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + Delivery delivery = new Delivery(returned.uuid(), Instant.now().plus(delay)); - // Content is overwritten with the actually-deposited items first (they may legitimately - // differ from the snapshot when check flags are relaxed); only then the status flip makes - // the parcel a live shipment. markReturned failing means a concurrent return/purge won. - return this.parcelContentManager.update(current.uuid(), depositedCopy) - .thenCompose(updated -> this.parcelService.markReturned(returned)) - .thenCompose(marked -> { - if (!Boolean.TRUE.equals(marked)) { - this.refund(player, refundableFee); - return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); - } - - // Past the commit point: the parcel is now a live SENT shipment and its content IS the - // deposited items. Nothing below may route to the refund/give-back recovery — undoing - // here would duplicate the items. The try/catch below makes that structural: even a - // synchronous throw (e.g. the scheduler rejecting work during plugin disable) is only - // logged, never allowed to reach the outer exceptionally. - try { - if (refundableFee > 0) { - this.noticeService.create() - .notice(messages -> messages.parcel.returnFeeWithdrawn) - .player(player.getUniqueId()) - .placeholder(PLACEHOLDER_AMOUNT, String.format("%.2f", refundableFee)) - .send(); - } - - // Best-effort cleanup: a leftover row is ignored by the purge task because the - // parcel is no longer COLLECTED. - this.collectedParcelRepository.delete(current.uuid()).exceptionally(throwable -> { - LOGGER.log(Level.WARNING, "Failed to delete collected_parcels row for returned parcel " - + current.uuid(), throwable); - return false; - }); + // Claim, content replacement, delivery persistence and collected-row cleanup are one + // transaction. A losing concurrent attempt cannot overwrite the winner's content. + return this.parcelReturnRepository.commit( + returned, + new ParcelContent(returned.uuid(), depositedCopy), + delivery + ).thenCompose(committed -> { + if (!Boolean.TRUE.equals(committed)) { + this.refund(player, refundableFee); + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } - Duration delay = returned.priority() - ? this.config.settings.priorityParcelSendDuration - : this.config.settings.parcelSendDuration; + // Past the commit point the durable delivery row makes this shipment recoverable at + // startup. Nothing below may route to refund/give-back recovery. + try { + this.parcelService.invalidate(returned.uuid()); + this.parcelContentManager.invalidate(returned.uuid()); + this.deliveryManager.invalidate(returned.uuid()); - // Schedule the send task before persisting the delivery: ParcelSendTask.decide treats - // a missing delivery row as "deliver when due", so the task is safe even if the - // delivery upsert below fails. - this.scheduler.runLaterAsync( - new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), - delay); + if (refundableFee > 0) { + this.noticeService.create() + .notice(messages -> messages.parcel.returnFeeWithdrawn) + .player(player.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, String.format("%.2f", refundableFee)) + .send(); + } - // Use update (upsert), not create: a stale delivery entry can still be cached for - // this UUID from the parcel's original trip (its cleanup delete is best-effort and - // re-cached on restart via cacheAll), and create() throws IllegalStateException on an - // existing cache entry. Throwing here must not happen post-commit. - this.deliveryManager.update(returned.uuid(), Instant.now().plus(delay)) - .exceptionally(throwable -> { - LOGGER.log(Level.SEVERE, "Failed to persist delivery for returned parcel " + returned.uuid() - + " (send task scheduled in-memory; a restart before it fires may strand the parcel)", - throwable); - return null; - }); + this.scheduler.runLaterAsync( + new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), + delay); - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); - } catch (Exception exception) { - LOGGER.log(Level.SEVERE, "Post-commit step threw for returned parcel " + current.uuid() - + ": the return is already committed (parcel is SENT, content holds the deposited " - + "items) so no refund/give-back recovery was attempted", exception); - } - return CompletableFuture.completedFuture(null); - }) + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + } catch (Exception exception) { + LOGGER.log(Level.SEVERE, "Post-commit step threw for returned parcel " + current.uuid() + + ": the return is already committed (parcel is SENT, content holds the deposited " + + "items) so no refund/give-back recovery was attempted", exception); + } + return CompletableFuture.completedFuture(null); + }) .exceptionally(throwable -> { this.refund(player, refundableFee); this.giveBack(player, deposited); diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java index 88c167fe2..db0418746 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java @@ -8,7 +8,7 @@ import java.util.UUID; @DatabaseTable(tableName = "collected_parcels") -class CollectedParcelTable { +public class CollectedParcelTable { static final String COLLECTED_AT_COLUMN = "collected_at"; diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java new file mode 100644 index 000000000..3c959a157 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java @@ -0,0 +1,16 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.parcel.Parcel; +import java.util.concurrent.CompletableFuture; + +public interface ParcelReturnRepository { + + /** + * Atomically claims a collected parcel for return and persists every durable part of the + * reverse shipment. Returns false when another operation already moved the parcel out of + * COLLECTED. + */ + CompletableFuture commit(Parcel returned, ParcelContent content, Delivery delivery); +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java new file mode 100644 index 000000000..c9793cd36 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java @@ -0,0 +1,61 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.repository.ParcelContentTable; +import com.eternalcode.parcellockers.database.DatabaseManager; +import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.repository.DeliveryTable; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelTable; +import com.j256.ormlite.misc.TransactionManager; +import com.j256.ormlite.stmt.UpdateBuilder; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +public class ParcelReturnRepositoryOrmLite extends AbstractRepositoryOrmLite + implements ParcelReturnRepository { + + public ParcelReturnRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { + super(databaseManager, scheduler); + } + + @Override + public CompletableFuture commit( + Parcel returned, + ParcelContent content, + Delivery delivery + ) { + Objects.requireNonNull(returned, "Returned parcel cannot be null"); + Objects.requireNonNull(content, "Returned parcel content cannot be null"); + Objects.requireNonNull(delivery, "Returned parcel delivery cannot be null"); + + return this.action(ParcelTable.class, parcelDao -> + TransactionManager.callInTransaction(this.databaseManager.connectionSource(), () -> { + UpdateBuilder update = parcelDao.updateBuilder(); + update.updateColumnValue("sender", returned.sender()); + update.updateColumnValue("receiver", returned.receiver()); + update.updateColumnValue("entry_locker", returned.entryLocker()); + update.updateColumnValue("destination_locker", returned.destinationLocker()); + update.updateColumnValue("status", ParcelStatus.SENT); + update.where() + .eq("uuid", returned.uuid()) + .and() + .eq("status", ParcelStatus.COLLECTED); + + if (update.update() == 0) { + return false; + } + + this.databaseManager.getDao(ParcelContentTable.class) + .createOrUpdate(ParcelContentTable.from(content)); + this.databaseManager.getDao(DeliveryTable.class) + .createOrUpdate(DeliveryTable.from(delivery)); + this.databaseManager.getDao(CollectedParcelTable.class) + .deleteById(returned.uuid()); + return true; + })); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java new file mode 100644 index 000000000..150ebe085 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java @@ -0,0 +1,189 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepository; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepositoryOrmLite; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.repository.DeliveryRepository; +import com.eternalcode.parcellockers.delivery.repository.DeliveryRepositoryOrmLite; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepositoryOrmLite; +import java.io.File; +import java.lang.reflect.Field; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.UnsafeValues; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ParcelReturnCommitRepositoryIntegrationTest extends IntegrationTestSpec { + + private DatabaseManager databaseManager; + private TestScheduler scheduler; + private ParcelRepository parcelRepository; + private ParcelContentRepository contentRepository; + private DeliveryRepository deliveryRepository; + private CollectedParcelRepository collectedRepository; + private ParcelReturnRepository returnRepository; + + @BeforeEach + void setUp() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.H2; + + File dataFolder = new File("build/tmp/parcel-return-" + UUID.randomUUID()); + this.databaseManager = new DatabaseManager( + config, Logger.getLogger("ParcelLockers"), dataFolder); + this.databaseManager.connect(); + this.scheduler = new TestScheduler(); + this.parcelRepository = new ParcelRepositoryOrmLite(this.databaseManager, this.scheduler); + this.contentRepository = new ParcelContentRepositoryOrmLite(this.databaseManager, this.scheduler); + this.deliveryRepository = new DeliveryRepositoryOrmLite(this.databaseManager, this.scheduler); + this.collectedRepository = new CollectedParcelRepositoryOrmLite(this.databaseManager, this.scheduler); + this.returnRepository = new ParcelReturnRepositoryOrmLite(this.databaseManager, this.scheduler); + } + + @Test + void concurrentReturnsKeepTheWinningContentAndDelivery() throws ReflectiveOperationException { + Parcel collected = collectedParcel(); + ItemStack originalStack = stack((byte) 1); + ItemStack firstStack = stack((byte) 2); + ItemStack secondStack = stack((byte) 3); + + installDeserializer(Map.of( + (byte) 1, originalStack, + (byte) 2, firstStack, + (byte) 3, secondStack + )); + this.await(this.parcelRepository.save(collected)); + this.await(this.contentRepository.save(content(collected.uuid(), originalStack))); + this.await(this.collectedRepository.save(new CollectedParcel( + collected.uuid(), Instant.parse("2026-07-14T10:00:00Z")))); + + Parcel returned = returned(collected); + Instant firstDelivery = Instant.parse("2026-07-14T11:00:00Z"); + Instant secondDelivery = Instant.parse("2026-07-14T12:00:00Z"); + + CompletableFuture first = this.returnRepository.commit( + returned, content(collected.uuid(), firstStack), + new Delivery(collected.uuid(), firstDelivery)); + CompletableFuture second = this.returnRepository.commit( + returned, content(collected.uuid(), secondStack), + new Delivery(collected.uuid(), secondDelivery)); + + boolean firstWon = this.await(first); + boolean secondWon = this.await(second); + + assertNotEquals(firstWon, secondWon); + ItemStack expectedStack = firstWon ? firstStack : secondStack; + Instant expectedDelivery = firstWon ? firstDelivery : secondDelivery; + assertSame(expectedStack, this.await(this.contentRepository.find(collected.uuid())) + .orElseThrow().items().getFirst()); + assertEquals(expectedDelivery, this.await(this.deliveryRepository.find(collected.uuid())) + .orElseThrow().deliveryTimestamp()); + assertEquals(ParcelStatus.SENT, this.await(this.parcelRepository.findById(collected.uuid())) + .orElseThrow().status()); + assertTrue(this.await(this.collectedRepository.find(collected.uuid())).isEmpty()); + } + + @Test + void failedClaimDoesNotChangeContentOrDelivery() throws ReflectiveOperationException { + Parcel sent = returned(collectedParcel()); + Instant existingDelivery = Instant.parse("2026-07-14T11:00:00Z"); + ItemStack existingStack = stack((byte) 4); + ItemStack losingStack = stack((byte) 5); + + installDeserializer(Map.of( + (byte) 4, existingStack, + (byte) 5, losingStack + )); + this.await(this.parcelRepository.save(sent)); + this.await(this.contentRepository.save(content(sent.uuid(), existingStack))); + this.await(this.deliveryRepository.save(new Delivery(sent.uuid(), existingDelivery))); + + boolean committed = this.await(this.returnRepository.commit( + sent, content(sent.uuid(), losingStack), + new Delivery(sent.uuid(), Instant.parse("2026-07-14T12:00:00Z")))); + + assertFalse(committed); + assertSame(existingStack, this.await(this.contentRepository.find(sent.uuid())) + .orElseThrow().items().getFirst()); + assertEquals(existingDelivery, this.await(this.deliveryRepository.find(sent.uuid())) + .orElseThrow().deliveryTimestamp()); + } + + private static Parcel collectedParcel() { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "parcel", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), + ParcelStatus.COLLECTED); + } + + private static Parcel returned(Parcel collected) { + return new Parcel(collected.uuid(), collected.receiver(), collected.name(), + collected.description(), collected.priority(), collected.sender(), collected.size(), + collected.destinationLocker(), collected.entryLocker(), ParcelStatus.SENT); + } + + private static ParcelContent content(UUID parcel, ItemStack item) { + return new ParcelContent(parcel, List.of(item)); + } + + private static ItemStack stack(byte marker) { + ItemStack item = mock(ItemStack.class); + when(item.isEmpty()).thenReturn(false); + when(item.serializeAsBytes()).thenReturn(new byte[] { marker }); + return item; + } + + private static void installDeserializer(Map items) + throws ReflectiveOperationException { + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeItem(any(byte[].class))).thenAnswer(invocation -> { + byte[] serialized = invocation.getArgument(0); + return items.get(serialized[0]); + }); + Server server = mock(Server.class); + when(server.getUnsafe()).thenReturn(unsafe); + Field serverField = Bukkit.class.getDeclaredField("server"); + serverField.setAccessible(true); + serverField.set(null, server); + } + + @AfterEach + void tearDown() { + if (this.scheduler != null) { + this.scheduler.shutdown(); + } + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java index debf5b1a6..dc5dc7e52 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java @@ -74,29 +74,6 @@ void markCollectedFlipsOnlyDeliveredParcels() throws SQLException { assertFalse(this.await(repository.markCollected(sent.uuid()))); } - @Test - void markReturnedSwapsPartiesAndLockersOnlyWhenCollected() throws SQLException { - ParcelRepository repository = this.repository(); - Parcel collected = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED); - this.await(repository.save(collected)); - - Parcel returned = new Parcel(collected.uuid(), collected.receiver(), collected.name(), - collected.description(), collected.priority(), collected.sender(), collected.size(), - collected.destinationLocker(), collected.entryLocker(), ParcelStatus.SENT); - - assertTrue(this.await(repository.markReturned(returned))); - - Parcel stored = this.await(repository.findById(collected.uuid())).orElseThrow(); - assertEquals(collected.receiver(), stored.sender()); - assertEquals(collected.sender(), stored.receiver()); - assertEquals(collected.destinationLocker(), stored.entryLocker()); - assertEquals(collected.entryLocker(), stored.destinationLocker()); - assertEquals(ParcelStatus.SENT, stored.status()); - - // A second return of the same parcel must fail (status is no longer COLLECTED). - assertFalse(this.await(repository.markReturned(returned))); - } - @Test void findReturnableReturnsOnlyCollectedParcelsOfReceiver() throws SQLException { ParcelRepository repository = this.repository(); diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java new file mode 100644 index 000000000..ec6450ba1 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java @@ -0,0 +1,85 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.shared.PageResult; +import dev.triumphteam.gui.TriumphGui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.lang.reflect.Field; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ReturnGuiTest { + + @Test + void emptyResultMutatesGuiOnlyInsideScheduledMainThreadCallback() throws ReflectiveOperationException { + GuiSettings settings = mock(GuiSettings.class); + settings.parcelReturnGuiTitle = "Return parcels"; + settings.parcelReturnRowItem = mock(ConfigItem.class); + settings.closeItem = mock(ConfigItem.class); + settings.cornerItem = mock(ConfigItem.class); + settings.mainGuiBackgroundItem = mock(ConfigItem.class); + settings.noReturnableParcelsItem = mock(ConfigItem.class); + GuiItem emptyItem = mock(GuiItem.class); + when(settings.noReturnableParcelsItem.toGuiItem()).thenReturn(emptyItem); + + Scheduler scheduler = mock(Scheduler.class); + GuiManager guiManager = mock(GuiManager.class); + MiniMessage miniMessage = mock(MiniMessage.class); + NoticeService noticeService = mock(NoticeService.class); + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + Field serverField = Bukkit.class.getDeclaredField("server"); + serverField.setAccessible(true); + serverField.set(null, server); + TriumphGui.init(mock(Plugin.class)); + PaginatedGui gui = mock(PaginatedGui.class); + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerId); + when(miniMessage.deserialize("Return parcels")).thenReturn(Component.empty()); + CompletableFuture> result = new CompletableFuture<>(); + when(guiManager.getReturnableParcels(any(), any())).thenReturn(result); + + Function guiFactory = ignored -> gui; + ReturnGui returnGui = new ReturnGui( + settings, scheduler, guiManager, miniMessage, noticeService, guiFactory); + returnGui.show(player); + clearInvocations(gui, scheduler); + + result.complete(PageResult.empty()); + + verify(gui, never()).setItem(anyInt(), any(GuiItem.class)); + verify(gui, never()).open(player); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).run(callback.capture()); + + callback.getValue().run(); + + verify(gui).setItem(22, emptyItem); + verify(gui).open(player); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index ab1a59b9f..7c44e204c 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -2,10 +2,39 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; import java.time.Duration; import java.time.Instant; +import java.util.List; +import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import net.milkbowl.vault.economy.Economy; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; class ParcelReturnServiceTest { @@ -30,4 +59,123 @@ void exactExpiryInstantIsOutsideWindow() { CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(WINDOW)); assertFalse(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); } + + @Test + void waitsForDurableCommitBeforeSchedulingOrReportingSuccess() { + Fixture fixture = new Fixture(); + fixture.validReturn(); + CompletableFuture commit = new CompletableFuture<>(); + when(fixture.returnRepository.commit(any(), any(), any())).thenReturn(commit); + + CompletableFuture result = fixture.service.returnParcel( + fixture.player, fixture.parcel, fixture.deposited); + + assertFalse(result.isDone()); + verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); + verify(fixture.noticeService, never()).player(eq(fixture.playerId), any()); + + commit.complete(true); + result.join(); + + verify(fixture.scheduler).runLaterAsync(any(Runnable.class), any(Duration.class)); + verify(fixture.noticeService).player(eq(fixture.playerId), any()); + } + + @Test + void rejectsReturnWhenOriginalEntryLockerNoLongerExists() { + Fixture fixture = new Fixture(); + fixture.validReturn(); + when(fixture.lockerManager.get(fixture.parcel.entryLocker())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited).join(); + + verify(fixture.lockerManager, never()).isLockerFull(any()); + verify(fixture.returnRepository, never()).commit(any(), any(), any()); + verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + } + + @Test + void capturesFeeBypassPermissionBeforeRepositoryContinuation() { + Fixture fixture = new Fixture(); + CompletableFuture> pendingParcel = new CompletableFuture<>(); + when(fixture.parcelService.get(fixture.parcel.uuid())).thenReturn(pendingParcel); + + fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited); + + verify(fixture.player).hasPermission("parcellockers.fee.bypass"); + } + + private static final class Fixture { + + private final UUID playerId = UUID.randomUUID(); + private final Player player = mock(Player.class); + private final ParcelService parcelService = mock(ParcelService.class); + private final ParcelContentManager contentManager = mock(ParcelContentManager.class); + private final CollectedParcelRepository collectedRepository = mock(CollectedParcelRepository.class); + private final DeliveryManager deliveryManager = mock(DeliveryManager.class); + private final LockerManager lockerManager = mock(LockerManager.class); + private final ParcelReturnValidator validator = mock(ParcelReturnValidator.class); + private final ParcelReturnRepository returnRepository = mock(ParcelReturnRepository.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final NoticeService noticeService = mock(NoticeService.class); + private final Economy economy = mock(Economy.class); + private final Server server = mock(Server.class); + private final PluginManager pluginManager = mock(PluginManager.class); + private final PluginConfig config = new PluginConfig(); + private final ItemStack item = mock(ItemStack.class); + private final List deposited = List.of(this.item); + private final Parcel parcel = new Parcel( + UUID.randomUUID(), + UUID.randomUUID(), + "parcel", + "description", + false, + this.playerId, + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.COLLECTED + ); + private final ParcelReturnService service; + + private Fixture() { + this.config.settings.smallParcelReturnFee = 0; + when(this.player.getUniqueId()).thenReturn(this.playerId); + when(this.player.getName()).thenReturn("Player"); + when(this.item.clone()).thenReturn(this.item); + when(this.server.getPluginManager()).thenReturn(this.pluginManager); + this.service = new ParcelReturnService( + this.parcelService, + this.contentManager, + this.collectedRepository, + this.deliveryManager, + this.lockerManager, + this.validator, + this.returnRepository, + this.scheduler, + this.config, + this.noticeService, + this.economy, + this.server + ); + } + + private void validReturn() { + ParcelContent content = new ParcelContent(this.parcel.uuid(), this.deposited); + when(this.parcelService.get(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(this.parcel))); + when(this.collectedRepository.find(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of( + new CollectedParcel(this.parcel.uuid(), Instant.now())))); + when(this.contentManager.get(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(content))); + when(this.validator.matches(this.deposited, this.deposited)).thenReturn(true); + when(this.lockerManager.get(this.parcel.entryLocker())) + .thenReturn(CompletableFuture.completedFuture(Optional.of( + new Locker(this.parcel.entryLocker(), "Entry", null)))); + when(this.lockerManager.isLockerFull(this.parcel.entryLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + } + } } From 93ce93f0b0a44c76b59ea2faa98fdfcbfb0d745c Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:05:45 +0200 Subject: [PATCH 19/29] Add AGENTS.md --- AGENTS.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4f7cf18ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +# Build the plugin JAR (output: build/libs/ParcelLockers v.jar) +./gradlew shadowJar + +# Run tests +./gradlew test + +# Run a single test class +./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelRepositoryIntegrationTest" + +# Start a local Paper server with the plugin loaded (downloads Paper + dependencies automatically) +./gradlew runServer +``` + +Requires JDK 21+. The `runServer` task uses JetBrains JVM and auto-downloads LuckPerms, VaultUnlocked, and EssentialsX. Uncomment the DiscordSRV line in `build.gradle.kts` to test that integration locally. + +## Architecture Overview + +ParcelLockers is a Paper plugin (Minecraft 1.21) that lets players transfer items between parcel locker blocks across the world, with optional Discord notifications. + +### Entry Point & Wiring + +`ParcelLockers.java` (`onEnable`) is the manual DI root — all components are instantiated and wired there in order: config → database → repositories → managers/services → GUIs → commands → event controllers. There is no DI framework; dependencies are passed via constructors. + +### Domain Layers + +Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, `discord`) follows a consistent layered structure: + +- **Model** — plain record/class (e.g. `Locker`, `Parcel`, `User`) +- **Repository** — interface + `*OrmLite` implementation backed by H2/PostgreSQL via ORMLite +- **Manager/Service** — business logic, coordinates repository calls; repositories return `CompletableFuture` for all async DB operations +- **Controller** — Bukkit `Listener` handling in-game events (block place/break/interact, player join/quit) + +### Key Components + +| Component | Purpose | +|---|---| +| `DatabaseManager` | Manages HikariCP connection pool; supports H2 (default, embedded) and PostgreSQL | +| `ConfigService` + okaeri-configs | Loads `config.yml` and `messages.yml` via YAML; `PluginConfig` and `MessageConfig` are the config POJOs | +| `NoticeService` + multification | Sends MiniMessage-formatted notices to players; all user-facing text goes through `MessageConfig` | +| `ParcelDispatchService` | Orchestrates sending a parcel: validates, charges economy (Vault), schedules `ParcelSendTask` | +| `ParcelSendTask` | Runs async after a configurable delay; marks parcel DELIVERED and fires the deliver notification event | +| `GuiManager` + triumph-gui | Factory for all inventory GUIs; `LockerGui` and `MainGui` are the two root GUI entry points | +| `DiscordProviderPicker` | Selects between Discord4J (standalone bot) and DiscordSRV (delegation) at startup based on detected plugins | +| `LockerPlaceController` | Uses Paper's Dialog API (unstable) to prompt for a locker description when a player places the locker item | + +### Optional Integrations + +- **DiscordSRV** — when present, account linking and DM notifications are delegated to it; otherwise, the plugin manages its own Discord bot via Discord4J +- **Nexo** — when present, custom item blocks can be used as locker blocks (`NexoIntegration.placeBlock`); guarded by `isPluginEnabled("Nexo")` checks +- **Vault** — required; used to charge players an economy fee when sending parcels + +### Testing + +Tests live in `src/test/java/`. Integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `IntegrationTestSpec` and use Testcontainers (MySQL) to test repository implementations against a real database. `ParcelPageTest` is a unit test with no container dependency. From 8c117c22127b53f2a4fd9b3975b5ae03522fb5f8 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:47:45 +0200 Subject: [PATCH 20/29] feat: add configurable parcel return name format --- .../2026-07-14-parcel-return-name-format.md | 147 ++++++++++++++++++ ...-07-14-parcel-return-name-format-design.md | 41 +++++ .../implementation/PluginConfig.java | 7 + .../returns/ParcelReturnService.java | 3 +- .../returns/ParcelReturnServiceTest.java | 37 +++++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-14-parcel-return-name-format.md create mode 100644 docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md diff --git a/docs/superpowers/plans/2026-07-14-parcel-return-name-format.md b/docs/superpowers/plans/2026-07-14-parcel-return-name-format.md new file mode 100644 index 000000000..ba32665cb --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-parcel-return-name-format.md @@ -0,0 +1,147 @@ +# Parcel Return Name Format Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a configurable template that names returned parcels `[Refund] ` by default. + +**Architecture:** Store the template in `PluginConfig.Settings` and apply it when `ParcelReturnService` constructs the reversed `Parcel`. The already-atomic return repository persists the formatted name together with the other return state. + +**Tech Stack:** Java 21, Okaeri Configs, CompletableFuture, JUnit 6, Mockito, Gradle + +## Global Constraints + +- The config field is named `parcelReturnNameFormat` and defaults to `[Refund] {NAME}`. +- Every `{NAME}` occurrence is replaced with the current parcel name. +- A template without `{NAME}` becomes the complete returned-parcel name. +- No GUI, message, notification, fee, timing, or persistence behavior changes. +- Do not commit; the user will create the commit. + +--- + +### Task 1: Configure and apply the returned-parcel name template + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java:97-110` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java:186-203` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: `PluginConfig.Settings`, `Parcel.name()`, and `ParcelReturnRepository.commit(Parcel, ParcelContent, Delivery)`. +- Produces: `PluginConfig.Settings#parcelReturnNameFormat` as a public `String`; the committed returned `Parcel` contains the formatted name. + +- [ ] **Step 1: Write failing behavior tests** + +Add the assertion and captor imports: + +```java +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.mockito.ArgumentCaptor; +``` + +Add these tests to `ParcelReturnServiceTest`: + +```java +@Test +void appliesDefaultPrefixToReturnedParcelName() { + Fixture fixture = new Fixture(); + + assertEquals("[Refund] parcel", fixture.returnedParcelName()); +} + +@Test +void appliesCustomReturnedParcelNameFormat() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned: {NAME} / {NAME}"; + + assertEquals("Returned: parcel / parcel", fixture.returnedParcelName()); +} + +@Test +void usesLiteralReturnedParcelNameWhenFormatHasNoPlaceholder() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned parcel"; + + assertEquals("Returned parcel", fixture.returnedParcelName()); +} +``` + +Add this helper inside `Fixture`: + +```java +private String returnedParcelName() { + this.validReturn(); + when(this.returnRepository.commit(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + this.service.returnParcel(this.player, this.parcel, this.deposited).join(); + + ArgumentCaptor returnedParcel = ArgumentCaptor.forClass(Parcel.class); + verify(this.returnRepository).commit(returnedParcel.capture(), any(), any()); + return returnedParcel.getValue().name(); +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --no-configuration-cache --no-daemon +``` + +Expected: the new tests fail because returned parcels still contain `parcel`; before adding the config field, the two custom-format tests may first fail compilation because `parcelReturnNameFormat` does not exist. Add only the config field declaration if compilation blocks observing the behavioral failures, then rerun and confirm the name assertions fail before changing `ParcelReturnService`. + +- [ ] **Step 3: Add the minimal configuration and formatting implementation** + +Add this field beside the parcel-return window and fees in `PluginConfig.Settings`: + +```java +@Comment({ + "", + "# The name format applied when a parcel is returned.", + "# Placeholder: {NAME} - the original parcel name." +}) +public String parcelReturnNameFormat = "[Refund] {NAME}"; +``` + +In `ParcelReturnService.proceedWithReturn`, replace construction with: + +```java +String returnedName = this.config.settings.parcelReturnNameFormat.replace("{NAME}", current.name()); +Parcel returned = new Parcel(current.uuid(), current.receiver(), returnedName, + current.description(), current.priority(), current.sender(), current.size(), + current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: + +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --no-configuration-cache --no-daemon +``` + +Expected: all `ParcelReturnServiceTest` tests pass, including the three format cases. + +- [ ] **Step 5: Run deterministic regression tests** + +Run: + +```powershell +.\gradlew.bat cleanTest test --tests "com.eternalcode.parcellockers.returns.ReturnItemEquivalenceTest" --tests "com.eternalcode.parcellockers.returns.ParcelReturnValidatorTest" --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --tests "com.eternalcode.parcellockers.parcel.task.ParcelSendTaskTest" --tests "com.eternalcode.parcellockers.parcel.service.AdminParcelServiceTest" --tests "com.eternalcode.parcellockers.parcel.repository.ParcelPageTest" --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnGuiTest" --tests "com.eternalcode.parcellockers.gui.implementation.admin.AdminParcelEditGuiTest" --tests "com.eternalcode.parcellockers.content.ParcelContentTest" --tests "com.eternalcode.parcellockers.database.ParcelReturnCommitRepositoryIntegrationTest" --no-configuration-cache --no-daemon +``` + +Expected: all deterministic tests pass with zero failures and zero errors. + +- [ ] **Step 6: Build and inspect the patch** + +Run: + +```powershell +.\gradlew.bat shadowJar --no-configuration-cache --no-daemon +git diff --check +git status --short +``` + +Expected: `shadowJar` succeeds, `git diff --check` reports no whitespace errors, and only the intended source, test, spec, and plan files are newly modified by this task. diff --git a/docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md b/docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md new file mode 100644 index 000000000..eb0607750 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md @@ -0,0 +1,41 @@ +# Configurable Parcel Return Name Format + +## Goal + +Allow server administrators to configure the name assigned to a returned parcel. Returned parcels should use a configurable template instead of preserving the original name unchanged. + +## Configuration + +Add `parcelReturnNameFormat` to `PluginConfig.Settings`, next to the existing parcel-return settings. + +Default value: + +```text +[Refund] {NAME} +``` + +Okaeri Configs will expose this field in the generated YAML as `parcelReturnNameFormat`, matching the existing camelCase configuration keys. + +`{NAME}` represents the parcel name immediately before the return is committed. Every occurrence of the placeholder is replaced. If the configured value contains no `{NAME}` placeholder, the configured value becomes the complete returned-parcel name. + +## Return Flow + +When `ParcelReturnService` constructs the returned `Parcel`, it formats the name using `config.settings.parcelReturnNameFormat` and the current parcel name. The formatted name is included in the existing atomic return transaction, so no additional persistence operation is introduced. + +No other parcel fields, GUI labels, messages, or notification behavior change. + +## Compatibility + +Existing installations receive the new default during config migration/generation. Therefore, after updating, returned parcels are named `[Refund] ` unless the administrator changes the format to `{NAME}` or another template. + +Formatting codes are stored as part of the parcel name exactly as configured; rendering remains the responsibility of the existing GUI/message pipeline. + +## Testing + +Extend `ParcelReturnServiceTest` using the existing mocked atomic repository boundary: + +- verify the default format produces `[Refund] ` in the committed parcel; +- verify a custom format replaces `{NAME}` with the original name; +- verify a format without `{NAME}` becomes the complete returned-parcel name. + +Implementation follows test-driven development: add and run a failing behavior test before changing production code, then implement the smallest formatting change and rerun the focused and deterministic suites. diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java index 083e217a5..05ead4753 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -98,6 +98,13 @@ public static class Settings extends OkaeriConfig { @Comment({"", "# How long after collection a parcel can still be returned.", "# Expired collected parcels are purged periodically."}) public Duration parcelReturnWindow = Duration.ofDays(7); + @Comment({ + "", + "# The name format applied when a parcel is returned.", + "# Placeholder: {NAME} - the original parcel name." + }) + public String parcelReturnNameFormat = "[Refund] {NAME}"; + @Comment({"", "# Small parcel return fee in in-game currency"}) public double smallParcelReturnFee = 5.0; diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index e9fdbfc7b..21aa82021 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -184,7 +184,8 @@ private CompletableFuture execute( } private CompletableFuture proceedWithReturn(Player player, Parcel current, List deposited, double refundableFee) { - Parcel returned = new Parcel(current.uuid(), current.receiver(), current.name(), + String returnedName = this.config.settings.parcelReturnNameFormat.replace("{NAME}", current.name()); + Parcel returned = new Parcel(current.uuid(), current.receiver(), returnedName, current.description(), current.priority(), current.sender(), current.size(), current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index 7c44e204c..55415314b 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.returns; +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.ArgumentMatchers.any; @@ -36,6 +37,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; class ParcelReturnServiceTest { @@ -106,6 +108,29 @@ void capturesFeeBypassPermissionBeforeRepositoryContinuation() { verify(fixture.player).hasPermission("parcellockers.fee.bypass"); } + @Test + void appliesDefaultPrefixToReturnedParcelName() { + Fixture fixture = new Fixture(); + + assertEquals("[Refund] parcel", fixture.returnedParcelName()); + } + + @Test + void appliesCustomReturnedParcelNameFormat() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned: {NAME} / {NAME}"; + + assertEquals("Returned: parcel / parcel", fixture.returnedParcelName()); + } + + @Test + void usesLiteralReturnedParcelNameWhenFormatHasNoPlaceholder() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned parcel"; + + assertEquals("Returned parcel", fixture.returnedParcelName()); + } + private static final class Fixture { private final UUID playerId = UUID.randomUUID(); @@ -177,5 +202,17 @@ private void validReturn() { when(this.lockerManager.isLockerFull(this.parcel.entryLocker())) .thenReturn(CompletableFuture.completedFuture(false)); } + + private String returnedParcelName() { + this.validReturn(); + when(this.returnRepository.commit(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + this.service.returnParcel(this.player, this.parcel, this.deposited).join(); + + ArgumentCaptor returnedParcel = ArgumentCaptor.forClass(Parcel.class); + verify(this.returnRepository).commit(returnedParcel.capture(), any(), any()); + return returnedParcel.getValue().name(); + } } } From 79910916194b774e0e5985ff78d5bc4dc0213f4e Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:34:47 +0200 Subject: [PATCH 21/29] docs: design detailed return mismatch messages --- ...4-parcel-return-mismatch-details-design.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md diff --git a/docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md b/docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md new file mode 100644 index 000000000..82d50032c --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md @@ -0,0 +1,89 @@ +# Detailed Parcel Return Mismatch Messages + +## Goal + +When a player deposits items to return a collected parcel and those items do not match the original contents, report every mismatch category and identify the affected item. Preserve the existing safety behavior: reject the return, give all deposited items back, and perform no fee withdrawal, database commit, or delivery scheduling. + +## Validation Result + +Replace the validator's boolean-only result with a structured result that contains whether the deposit matches and an ordered list of item-specific mismatches. Keep a boolean convenience method only where it avoids unnecessary caller churn; the structured result is the source of truth used by `ParcelReturnService`. + +Mismatch categories are: + +- unexpected material; +- insufficient amount; +- excess amount; +- durability; +- custom item name; +- enchantments; +- lore; +- other NBT/item metadata. + +Checks disabled in `PluginConfig.ReturnChecks` do not reject a return and do not appear in the mismatch list. Material and total amount remain mandatory checks. + +`ReturnItemEquivalence` exposes an attribute comparison in addition to its predicate-compatible boolean result. The attribute comparison isolates explicit attributes from residual metadata so, for example, an enchantment difference is reported as `enchantments`, not redundantly as both `enchantments` and `other item metadata`. The existing equivalence semantics remain unchanged. + +## Matching Algorithm + +Validation works on remaining quantities rather than physical stack boundaries, preserving the current behavior where split and merged stacks are equivalent. + +1. Consume deposited quantities that exactly match an expected equivalence group. +2. For remaining quantities, pair stacks of the same material using the smallest number of enabled attribute differences. Input order breaks ties to keep the result deterministic. +3. Report every attribute category found for each paired item and consume the paired quantity. +4. Report remaining expected quantities as insufficient amounts. +5. Report remaining deposited quantities as excess amounts when that material was expected, or as unexpected items when it was not expected. +6. Aggregate duplicate findings for the same item and category so the notice stays concise. + +Pairing same-material items after exact matches prevents an altered sword from being presented as both a missing original sword and an unrelated extra sword. If multiple variants of one material exist, closest-pair matching produces the most useful explanation without changing the acceptance rules. + +## Message Configuration + +Keep `MessageConfig.ParcelMessages.returnItemsMismatch` as the single failure notice and add a `{MISMATCHES}` placeholder. Add configurable templates for each reason and for joining multiple reasons, ensuring all player-facing wording remains in `MessageConfig`. + +Default output lists each aggregated reason on a separate line. Examples: + +```text +Diamond Sword: insufficient amount (expected 2, deposited 1) +Diamond Sword: enchantments differ +Diamond Sword: durability differs (expected damage 4, deposited 27) +Dirt: unexpected item (deposited 16) +``` + +Amounts and durability include expected and deposited numeric values. Material mismatches include readable material names. Custom names, enchantments, lore, and arbitrary metadata identify the item and mismatch category; potentially long or unsafe raw metadata is not dumped into chat. + +The empty-deposit shortcut in `ReturnDepositGui` is removed. Confirming an empty deposit invokes the normal return service, allowing the validator to report each missing expected item through the same configured notice. + +## Service Flow and Failure Handling + +After loading the current parcel content, `ParcelReturnService` asks the validator for a structured result. A valid result continues through the existing locker, fee, and atomic-commit flow. An invalid result formats every mismatch with `MessageConfig`, supplies the combined value as `{MISMATCHES}`, gives the deposited items back, and completes without further return processing. + +All other abort paths retain their current notices. Diagnostic formatting is side-effect-free and contains no shared mutable state, so concurrent asynchronous returns cannot leak mismatch details between players. + +## Testing + +Implementation follows test-driven development. Focused tests are added and observed failing before production changes. + +`ReturnItemEquivalenceTest` covers: + +- each explicit mismatch category; +- disabled checks; +- residual NBT detection without duplicate explicit categories; +- unchanged boolean equivalence behavior. + +`ParcelReturnValidatorTest` covers: + +- exact, split, and merged matches; +- insufficient and excess quantities with expected/deposited values; +- unexpected materials; +- several simultaneous categories; +- closest same-material pairing; +- empty deposits; +- aggregation and deterministic ordering. + +Service and GUI-level tests cover: + +- one configured notice containing all formatted item-specific reasons; +- deposited-item restoration and absence of fee, commit, and scheduling on mismatch; +- empty confirmation reaching normal validation rather than sending the old generic GUI notice. + +After focused tests pass, run the complete unit-test suite. Existing user changes in `build.gradle.kts` and `PR_REVIEW_feat-parcel-return-gh-69.md` are outside this work and remain untouched. From c23ac4bfed58192519b60886944f635f393cef03 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:47:22 +0200 Subject: [PATCH 22/29] docs: plan detailed return mismatch messages --- ...26-07-14-parcel-return-mismatch-details.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md diff --git a/docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md b/docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md new file mode 100644 index 000000000..86694d234 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md @@ -0,0 +1,368 @@ +# Detailed Parcel Return Mismatch Messages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the generic refunded-parcel content mismatch notice with one configurable notice that lists every item-specific mismatch category. + +**Architecture:** `ReturnItemEquivalence` becomes a diagnostic comparator while preserving predicate behavior. `ParcelReturnValidator` returns immutable diagnostics, `ReturnMismatchFormatter` renders them through `MessageConfig`, and `ParcelReturnService` sends one formatted notice while retaining the current give-back and atomic-return boundaries. + +**Tech Stack:** Java 21, Paper/Bukkit, Okaeri Configs, multification, JUnit 6, Mockito, Gradle. + +## Global Constraints + +- Material and total amount checks are mandatory. +- Disabled attribute checks neither reject a return nor appear in diagnostics. +- Report all categories with the affected material; include numeric values for amounts and durability. +- Do not expose raw lore or arbitrary NBT in chat. +- Invalid returns give deposited items back and do not withdraw fees, commit, or schedule delivery. +- Do not stage or change the user's `build.gradle.kts` or `PR_REVIEW_feat-parcel-return-gh-69.md`. + +## File Structure + +- `ReturnItemComparator` and `ReturnItemDifference`: low-level diagnostic comparison. +- `ReturnMismatchType`, `ReturnItemMismatch`, and `ParcelReturnValidationResult`: parcel-level diagnostics. +- `ParcelReturnValidator`: exact-first, closest-same-material reconciliation. +- `ReturnMismatchFormatter` and `MessageConfig`: configurable rendering. +- `ParcelReturnService`, `ParcelLockers`, and `ReturnDepositGui`: runtime integration. + +--- + +### Task 1: Diagnostic Item Comparison + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java` + +**Interfaces:** +- Produces `EnumSet differences(ItemStack expected, ItemStack deposited)`. +- Retains predicate-compatible `test(expected, deposited)`. + +- [ ] **Step 1: Write failing diagnostic tests** + +Add exact-set assertions for material, durability, display name, enchantments, lore, residual metadata, and disabled checks: + +```java +assertEquals( + EnumSet.of(ReturnItemDifference.DURABILITY, ReturnItemDifference.ENCHANTMENTS), + equivalence.differences(expected, deposited) +); +``` + +For residual metadata, configure mocked clones so normalized `isSimilar` returns false and assert `EnumSet.of(ReturnItemDifference.NBT)`. Also retain the existing boolean assertions to prove compatibility. + +- [ ] **Step 2: Run the test and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ReturnItemEquivalenceTest" +``` + +Expected: compilation fails because the diagnostic API does not exist. + +- [ ] **Step 3: Add the contract and enum** + +```java +@FunctionalInterface +public interface ReturnItemComparator extends BiPredicate { + EnumSet differences(ItemStack expected, ItemStack deposited); + + @Override + default boolean test(ItemStack expected, ItemStack deposited) { + return this.differences(expected, deposited).isEmpty(); + } +} +``` + +```java +public enum ReturnItemDifference { + MATERIAL, DURABILITY, ITEM_NAME, ENCHANTMENTS, LORE, NBT +} +``` + +- [ ] **Step 4: Implement diagnostic equivalence** + +Make `ReturnItemEquivalence` implement `ReturnItemComparator`. Return `MATERIAL` immediately for different types. Compare each enabled explicit attribute and add its enum value. When `checkNbt` is enabled, clone both items, clear damage, display name, enchantments, and lore on both clones, then compare the normalized clones with `isSimilar`: + +```java +if (this.checks.checkNbt && !this.residualDataMatches(expected, deposited)) { + differences.add(ReturnItemDifference.NBT); +} +``` + +This residual comparison prevents explicit differences from also being labeled NBT. + +- [ ] **Step 5: Run the test and verify GREEN** + +Run the Task 1 test command. Expected: PASS. + +- [ ] **Step 6: Commit Task 1** + +```powershell +git add src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java +git commit -m "refactor: expose return item differences" +``` + +--- + +### Task 2: Structured Parcel Validation + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java` + +**Interfaces:** +- Consumes `ReturnItemComparator.differences(...)`. +- Produces `ParcelReturnValidationResult validate(List deposited, List expected)`. +- Keeps `matches(...)` as a delegate to `validate(...).matches()`. + +- [ ] **Step 1: Write failing structured-result tests** + +Use a material-only comparator: + +```java +private static final ReturnItemComparator BY_MATERIAL = (expected, deposited) -> + expected.getType() == deposited.getType() + ? EnumSet.noneOf(ReturnItemDifference.class) + : EnumSet.of(ReturnItemDifference.MATERIAL); +``` + +Assert exact ordered results for insufficient and excess amount, unexpected material, empty deposit, simultaneous attributes, exact-before-closest pairing, split/merged stacks, aggregation, and deterministic order: + +```java +assertEquals(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.unexpected(Material.DIRT, 2) +), result.mismatches()); +``` + +- [ ] **Step 2: Run the validator test and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ParcelReturnValidatorTest" +``` + +Expected: compilation fails because the result types do not exist. + +- [ ] **Step 3: Add immutable result types** + +```java +public enum ReturnMismatchType { + UNEXPECTED_ITEM, INSUFFICIENT_AMOUNT, EXCESS_AMOUNT, + DURABILITY, ITEM_NAME, ENCHANTMENTS, LORE, NBT +} +``` + +```java +public record ReturnItemMismatch( + ReturnMismatchType type, + Material item, + int expectedAmount, + int depositedAmount, + Integer expectedDamage, + Integer depositedDamage +) { + public static ReturnItemMismatch unexpected(Material item, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.UNEXPECTED_ITEM, item, 0, deposited, null, null); + } + + public static ReturnItemMismatch insufficient(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.INSUFFICIENT_AMOUNT, item, expected, deposited, null, null); + } + + public static ReturnItemMismatch excess(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.EXCESS_AMOUNT, item, expected, deposited, null, null); + } +} +``` + +Add an `attribute(type, expected, deposited)` factory that stores both damage values only for `DURABILITY`, and otherwise stores null damage values. Its private damage helper reads `Damageable#getDamage`, defaulting to zero. + +```java +public record ParcelReturnValidationResult(List mismatches) { + public ParcelReturnValidationResult { + mismatches = List.copyOf(mismatches); + } + + public boolean matches() { + return this.mismatches.isEmpty(); + } +} +``` + +- [ ] **Step 4: Implement deterministic reconciliation** + +Build insertion-ordered expected and deposited totals per material. Emit one amount or unexpected mismatch per material. Wrap stacks in candidates containing the stack and remaining quantity. Consume exact matches first. Then repeatedly select the equal-material pair with the smallest non-zero difference count, breaking ties by expected index and deposited index. Map differences with an exhaustive switch into `ReturnMismatchType`, add records to a `LinkedHashSet`, consume the smaller quantity, and continue. `MATERIAL` is never mapped because closest pairing considers equal materials only. + +- [ ] **Step 5: Run comparison and validator tests and verify GREEN** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ParcelReturnValidatorTest" --tests "com.eternalcode.parcellockers.returns.ReturnItemEquivalenceTest" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 2** + +```powershell +git add src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java +git commit -m "feat: diagnose parcel return mismatches" +``` + +--- + +### Task 3: Configurable Formatting + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java` +- Create: `src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java` + +**Interfaces:** +- Produces `String ReturnMismatchFormatter.format(ParcelReturnValidationResult result)`. + +- [ ] **Step 1: Write failing formatter tests** + +Create real `MessageConfig.ParcelMessages`, replace templates with marker strings, and assert every replacement and ordering: + +```java +messages.returnMismatchSeparator = " | "; +messages.returnMismatchInsufficientAmount = "{ITEM}:{EXPECTED_AMOUNT}/{DEPOSITED_AMOUNT}"; +messages.returnMismatchDurability = "{ITEM}:durability:{EXPECTED_DAMAGE}/{DEPOSITED_DAMAGE}"; +``` + +Expected example: `Diamond:5/4 | Diamond Sword:durability:4/27`. Also assert an empty result throws `IllegalArgumentException`. + +- [ ] **Step 2: Run the formatter test and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ReturnMismatchFormatterTest" +``` + +Expected: compilation fails because the formatter and message fields do not exist. + +- [ ] **Step 3: Add message templates** + +Put `{MISMATCHES}` on a second chat line in `returnItemsMismatch`, and add: + +```java +public String returnMismatchSeparator = ""; +public String returnMismatchUnexpectedItem = "&8- &f{ITEM}: &cunexpected item (deposited {DEPOSITED_AMOUNT})"; +public String returnMismatchInsufficientAmount = "&8- &f{ITEM}: &cinsufficient amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; +public String returnMismatchExcessAmount = "&8- &f{ITEM}: &cexcess amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; +public String returnMismatchDurability = "&8- &f{ITEM}: &cdurability differs (expected damage {EXPECTED_DAMAGE}, deposited {DEPOSITED_DAMAGE})"; +public String returnMismatchItemName = "&8- &f{ITEM}: &ccustom name differs"; +public String returnMismatchEnchantments = "&8- &f{ITEM}: &cenchantments differ"; +public String returnMismatchLore = "&8- &f{ITEM}: &clore differs"; +public String returnMismatchNbt = "&8- &f{ITEM}: &cother item data differs"; +``` + +- [ ] **Step 4: Implement the formatter** + +Hold `MessageConfig.ParcelMessages`, select the template with an exhaustive switch, replace `{ITEM}` through `MaterialUtil.format`, replace relevant numeric markers, and join lines with `returnMismatchSeparator`. Reject matching results because they have no diagnostic text. + +- [ ] **Step 5: Run the formatter test and verify GREEN** + +Run the Task 3 command. Expected: PASS. + +- [ ] **Step 6: Commit Task 3** + +```powershell +git add src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java +git commit -m "feat: format return mismatch reasons" +``` + +--- + +### Task 4: Service and Empty-Deposit Integration + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java` +- Modify: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` +- Create: `src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java` + +**Interfaces:** +- Consumes `ParcelReturnValidator.validate(...)` and `ReturnMismatchFormatter.format(...)`. +- Sends one configured notice with `{MISMATCHES}` replaced. + +- [ ] **Step 1: Write a failing service test** + +Return two mismatches from the validator and `firstsecond` from the formatter. Return a Mockito `NoticeBroadcast` with `RETURNS_SELF` from `noticeService.create()`. Verify: + +```java +verify(broadcast).placeholder("{MISMATCHES}", "firstsecond"); +verify(broadcast).send(); +verify(fixture.returnRepository, never()).commit(any(), any(), any()); +verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); +verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); +``` + +Capture the give-back scheduler callback to retain restoration coverage. + +- [ ] **Step 2: Write a failing empty-confirm GUI test** + +Add a package-private constructor accepting `IntFunction` as a GUI factory. Capture the `GuiAction` passed to `confirmReturnItem.toGuiItem(...)`, execute it with no deposited stacks, and assert: + +```java +verify(guiManager).returnParcel(player, parcel, List.of()); +verify(noticeService, never()).player(any(), any()); +``` + +- [ ] **Step 3: Run focused tests and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnDepositGuiTest" +``` + +Expected: service and GUI still use the old generic paths. + +- [ ] **Step 4: Integrate structured validation** + +Inject `ReturnMismatchFormatter` into `ParcelReturnService`. On invalid validation, give items back and send: + +```java +this.noticeService.create() + .notice(messages -> messages.parcel.returnItemsMismatch) + .player(player.getUniqueId()) + .placeholder("{MISMATCHES}", this.mismatchFormatter.format(validation)) + .send(); +``` + +Return a completed future immediately. Instantiate `new ReturnMismatchFormatter(messageConfig.parcel)` in `ParcelLockers.onEnable()` and pass it into the service. + +- [ ] **Step 5: Allow empty submissions** + +Remove the empty-list early notice from `ReturnDepositGui`. Always mark confirmed, close, and call `guiManager.returnParcel`. Retain the public Triumph GUI factory and use the injected factory only as the test seam. + +- [ ] **Step 6: Run focused tests and verify GREEN** + +Run the Task 4 command. Expected: PASS. + +- [ ] **Step 7: Run full verification** + +```powershell +./gradlew test +``` + +Expected: PASS. Report unavailable Docker separately if it prevents only Testcontainers execution. + +- [ ] **Step 8: Review and commit feature files** + +```powershell +git diff --check +git status --short +git diff -- src/main/java src/test/java +``` + +Stage only files named in Tasks 1-4, then commit: + +```powershell +git commit -m "feat: explain parcel return mismatches" +``` + +Confirm the user's `build.gradle.kts` and `PR_REVIEW_feat-parcel-return-gh-69.md` remain outside every commit. From 2c8630ce11bd6ffe44187840c26f401641e0ba1f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:50:32 +0200 Subject: [PATCH 23/29] build: expand supported Modrinth game versions --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6b47905b9..c29042eba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -153,7 +153,7 @@ modrinth { versionNumber.set(project.version.toString()) versionType.set(getVersionType(project.version.toString())) uploadFile.set(tasks.shadowJar) - gameVersions.addAll("1.21.11") + gameVersions.addAll("1.21.11", "26.1", "26.1.1", "26.1.2", "26.2") loaders.addAll("paper", "purpur") syncBodyFrom = rootProject.file("README.md").readText() } From 18bdf27b8ad96d2a3a3744272f43f7534f5f7fb2 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:55:31 +0200 Subject: [PATCH 24/29] refactor: expose return item differences --- .../returns/ReturnItemComparator.java | 16 ++++ .../returns/ReturnItemDifference.java | 10 +++ .../returns/ReturnItemEquivalence.java | 77 ++++++++----------- .../returns/ReturnItemEquivalenceTest.java | 54 ++++++++++--- 4 files changed, 103 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java new file mode 100644 index 000000000..f590ad457 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java @@ -0,0 +1,16 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.EnumSet; +import java.util.function.BiPredicate; +import org.bukkit.inventory.ItemStack; + +@FunctionalInterface +public interface ReturnItemComparator extends BiPredicate { + + EnumSet differences(ItemStack expected, ItemStack deposited); + + @Override + default boolean test(ItemStack expected, ItemStack deposited) { + return this.differences(expected, deposited).isEmpty(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java new file mode 100644 index 000000000..ded6608df --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java @@ -0,0 +1,10 @@ +package com.eternalcode.parcellockers.returns; + +public enum ReturnItemDifference { + MATERIAL, + DURABILITY, + ITEM_NAME, + ENCHANTMENTS, + LORE, + NBT +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java index 70d89157b..41eed6148 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java @@ -1,10 +1,10 @@ package com.eternalcode.parcellockers.returns; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.BiPredicate; import net.kyori.adventure.text.Component; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemStack; @@ -16,7 +16,7 @@ * Material must always match; amounts are compared by {@link ParcelReturnValidator}, * not here. The relation is symmetric. */ -public class ReturnItemEquivalence implements BiPredicate { +public class ReturnItemEquivalence implements ReturnItemComparator { private final PluginConfig.ReturnChecks checks; @@ -25,66 +25,53 @@ public ReturnItemEquivalence(PluginConfig.ReturnChecks checks) { } @Override - public boolean test(ItemStack expected, ItemStack actual) { - if (expected.getType() != actual.getType()) { - return false; + public EnumSet differences(ItemStack expected, ItemStack deposited) { + EnumSet differences = EnumSet.noneOf(ReturnItemDifference.class); + if (expected.getType() != deposited.getType()) { + differences.add(ReturnItemDifference.MATERIAL); + return differences; } - if (this.checks.checkNbt) { - if (this.allAttributeChecksEnabled()) { - return expected.isSimilar(actual); - } - return this.normalize(expected).isSimilar(this.normalize(actual)); + + ItemMeta expectedMeta = expected.getItemMeta(); + ItemMeta depositedMeta = deposited.getItemMeta(); + if (this.checks.checkDurability && damage(expectedMeta) != damage(depositedMeta)) { + differences.add(ReturnItemDifference.DURABILITY); + } + if (this.checks.checkItemName && !Objects.equals(displayName(expectedMeta), displayName(depositedMeta))) { + differences.add(ReturnItemDifference.ITEM_NAME); + } + if (this.checks.checkEnchantments && !enchants(expectedMeta).equals(enchants(depositedMeta))) { + differences.add(ReturnItemDifference.ENCHANTMENTS); + } + if (this.checks.checkLore && !Objects.equals(lore(expectedMeta), lore(depositedMeta))) { + differences.add(ReturnItemDifference.LORE); } - return this.attributesMatch(expected.getItemMeta(), actual.getItemMeta()); + if (this.checks.checkNbt && !this.residualDataMatches(expected, deposited)) { + differences.add(ReturnItemDifference.NBT); + } + return differences; } - private boolean allAttributeChecksEnabled() { - return this.checks.checkDurability - && this.checks.checkItemName - && this.checks.checkEnchantments - && this.checks.checkLore; + private boolean residualDataMatches(ItemStack expected, ItemStack deposited) { + return this.normalizeResidualData(expected).isSimilar(this.normalizeResidualData(deposited)); } - /** Strips the attributes whose check is disabled so isSimilar ignores them. */ - private ItemStack normalize(ItemStack item) { + private ItemStack normalizeResidualData(ItemStack item) { ItemStack copy = item.clone(); ItemMeta meta = copy.getItemMeta(); if (meta == null) { return copy; } - if (!this.checks.checkDurability && meta instanceof Damageable damageable) { + if (meta instanceof Damageable damageable) { damageable.setDamage(0); } - if (!this.checks.checkItemName) { - meta.displayName(null); - } - if (!this.checks.checkEnchantments) { - meta.getEnchants().keySet().forEach(meta::removeEnchant); - } - if (!this.checks.checkLore) { - meta.lore(null); - } + meta.displayName(null); + List.copyOf(meta.getEnchants().keySet()).forEach(meta::removeEnchant); + meta.lore(null); copy.setItemMeta(meta); return copy; } - /** checkNbt = false: compare only the enabled attributes. */ - private boolean attributesMatch(ItemMeta expected, ItemMeta actual) { - if (this.checks.checkDurability && damage(expected) != damage(actual)) { - return false; - } - if (this.checks.checkItemName && !Objects.equals(displayName(expected), displayName(actual))) { - return false; - } - if (this.checks.checkEnchantments && !enchants(expected).equals(enchants(actual))) { - return false; - } - if (this.checks.checkLore && !Objects.equals(lore(expected), lore(actual))) { - return false; - } - return true; - } - private static int damage(ItemMeta meta) { return meta instanceof Damageable damageable ? damageable.getDamage() : 0; } diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java index 401d810e9..2f7bde353 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.returns; +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,7 @@ import static org.mockito.Mockito.withSettings; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.EnumSet; import java.util.List; import java.util.Map; import net.kyori.adventure.text.Component; @@ -43,18 +45,57 @@ private static ItemMeta damagedMeta(int damage) { } @Test - void fullyStrictChecksDelegateToIsSimilar() { + void reportsEveryEnabledExplicitDifferenceWithoutDuplicatingNbt() { ItemStack expected = item(Material.DIAMOND_SWORD); ItemStack actual = item(Material.DIAMOND_SWORD); - when(expected.isSimilar(actual)).thenReturn(true); + ItemMeta expectedMeta = damagedMeta(4); + ItemMeta actualMeta = damagedMeta(27); + when(expectedMeta.getEnchants()).thenReturn(enchantsOf("sharpness", 3)); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + ItemStack normalizedExpected = normalizedClone(expected); + ItemStack normalizedActual = normalizedClone(actual); + when(normalizedExpected.isSimilar(normalizedActual)).thenReturn(true); ReturnItemEquivalence equivalence = new ReturnItemEquivalence(checks(true, true, true, true, true)); - assertTrue(equivalence.test(expected, actual)); - when(expected.isSimilar(actual)).thenReturn(false); + assertEquals( + EnumSet.of(ReturnItemDifference.DURABILITY, ReturnItemDifference.ENCHANTMENTS), + equivalence.differences(expected, actual) + ); assertFalse(equivalence.test(expected, actual)); } + @Test + void reportsResidualNbtOnlyWhenEnabled() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + ItemMeta expectedMeta = damagedMeta(0); + ItemMeta actualMeta = damagedMeta(0); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + ItemStack normalizedExpected = normalizedClone(expected); + ItemStack normalizedActual = normalizedClone(actual); + when(normalizedExpected.isSimilar(normalizedActual)).thenReturn(false); + + assertEquals( + EnumSet.of(ReturnItemDifference.NBT), + new ReturnItemEquivalence(checks(false, false, false, false, true)).differences(expected, actual) + ); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + private static ItemStack normalizedClone(ItemStack original) { + ItemStack clone = item(original.getType()); + ItemMeta meta = damagedMeta(0); + when(clone.getItemMeta()).thenReturn(meta); + when(original.clone()).thenReturn(clone); + return clone; + } + @Test void differentMaterialNeverMatches() { ItemStack expected = item(Material.DIAMOND_SWORD); @@ -93,11 +134,6 @@ void enchantmentMismatchFailsWhenChecked() { assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); } - /** - * Mocking Enchantment triggers its static registry lookup (needs a running server), so the - * key is a plain string smuggled through type erasure — getEnchants() map equality is all - * the equivalence compares. - */ @SuppressWarnings({"unchecked", "rawtypes"}) private static Map enchantsOf(String key, int level) { return (Map) Map.of(key, level); From 853d0fb216e8d3dc3ea7faa92dcd91deea39974a Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:58:34 +0200 Subject: [PATCH 25/29] feat: diagnose parcel return mismatches --- .../returns/ParcelReturnValidationResult.java | 14 ++ .../returns/ParcelReturnValidator.java | 186 ++++++++++++++---- .../returns/ReturnItemMismatch.java | 50 +++++ .../returns/ReturnMismatchType.java | 12 ++ .../returns/ParcelReturnValidatorTest.java | 126 +++++++++--- 5 files changed, 318 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java new file mode 100644 index 000000000..3b47877c7 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java @@ -0,0 +1,14 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.List; + +public record ParcelReturnValidationResult(List mismatches) { + + public ParcelReturnValidationResult { + mismatches = List.copyOf(mismatches); + } + + public boolean matches() { + return this.mismatches.isEmpty(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java index 60bec55af..828342ac9 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java @@ -1,63 +1,173 @@ package com.eternalcode.parcellockers.returns; import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; -import java.util.function.BiPredicate; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.bukkit.Material; import org.bukkit.inventory.ItemStack; /** - * Validates that deposited items are, as a multiset, exactly the original parcel content: - * every deposited stack must be equivalent to some original item, and the total amount per - * equivalence group must match. Stack splitting or merging is irrelevant. - * - *

Grouping ({@link #indexOf}) assumes {@code equivalence} is a true equivalence relation - * (reflexive, symmetric, transitive): each expected item is bucketed under the first - * sample it matches, and later items are folded into that same bucket without being re-compared - * to each other. If the predicate is not transitive (e.g. a "close enough" relaxed-attribute - * check where A~B and B~C but not A~C), distinct original items can collapse into one bucket - * and the amount check may pass for a return that should not have matched. + * Validates deposited items as a multiset. Physical stack splitting and merging do not + * affect validation. Exact variants are consumed before altered variants are diagnosed. */ public class ParcelReturnValidator { - private final BiPredicate equivalence; + private final ReturnItemComparator comparator; - public ParcelReturnValidator(BiPredicate equivalence) { - this.equivalence = equivalence; + public ParcelReturnValidator(ReturnItemComparator comparator) { + this.comparator = comparator; } - public boolean matches(List deposited, List expected) { - List samples = new ArrayList<>(); - List expectedTotals = new ArrayList<>(); - List depositedTotals = new ArrayList<>(); - - for (ItemStack item : expected) { - int index = this.indexOf(samples, item); - if (index < 0) { - samples.add(item); - expectedTotals.add(item.getAmount()); - depositedTotals.add(0); - continue; + public ParcelReturnValidationResult validate(List deposited, List expected) { + Objects.requireNonNull(deposited, "Deposited items cannot be null"); + Objects.requireNonNull(expected, "Expected items cannot be null"); + + Set mismatches = new LinkedHashSet<>(); + Map expectedTotals = totals(expected); + Map depositedTotals = totals(deposited); + + for (Map.Entry entry : expectedTotals.entrySet()) { + int expectedAmount = entry.getValue(); + int depositedAmount = depositedTotals.getOrDefault(entry.getKey(), 0); + if (depositedAmount < expectedAmount) { + mismatches.add(ReturnItemMismatch.insufficient(entry.getKey(), expectedAmount, depositedAmount)); + } else if (depositedAmount > expectedAmount) { + mismatches.add(ReturnItemMismatch.excess(entry.getKey(), expectedAmount, depositedAmount)); } - expectedTotals.set(index, expectedTotals.get(index) + item.getAmount()); } + for (Map.Entry entry : depositedTotals.entrySet()) { + if (!expectedTotals.containsKey(entry.getKey())) { + mismatches.add(ReturnItemMismatch.unexpected(entry.getKey(), entry.getValue())); + } + } + + List expectedCandidates = candidates(expected); + List depositedCandidates = candidates(deposited); + this.consumeExact(expectedCandidates, depositedCandidates); + this.diagnoseClosest(expectedCandidates, depositedCandidates, mismatches); + + return new ParcelReturnValidationResult(List.copyOf(mismatches)); + } - for (ItemStack item : deposited) { - int index = this.indexOf(samples, item); - if (index < 0) { - return false; + public boolean matches(List deposited, List expected) { + return this.validate(deposited, expected).matches(); + } + + private void consumeExact(List expected, List deposited) { + for (Candidate expectedItem : expected) { + for (Candidate depositedItem : deposited) { + if (expectedItem.remaining == 0 || depositedItem.remaining == 0) { + continue; + } + if (this.comparator.test(expectedItem.item, depositedItem.item)) { + consume(expectedItem, depositedItem); + } } - depositedTotals.set(index, depositedTotals.get(index) + item.getAmount()); } + } - return expectedTotals.equals(depositedTotals); + private void diagnoseClosest( + List expected, + List deposited, + Set mismatches + ) { + while (true) { + Pair closest = this.closestPair(expected, deposited); + if (closest == null) { + return; + } + Candidate expectedItem = expected.get(closest.expectedIndex); + Candidate depositedItem = deposited.get(closest.depositedIndex); + for (ReturnItemDifference difference : closest.differences) { + mismatches.add(ReturnItemMismatch.attribute( + mismatchType(difference), + expectedItem.item, + depositedItem.item + )); + } + consume(expectedItem, depositedItem); + } } - private int indexOf(List samples, ItemStack item) { - for (int i = 0; i < samples.size(); i++) { - if (this.equivalence.test(samples.get(i), item)) { - return i; + private Pair closestPair(List expected, List deposited) { + Pair closest = null; + for (int expectedIndex = 0; expectedIndex < expected.size(); expectedIndex++) { + Candidate expectedItem = expected.get(expectedIndex); + if (expectedItem.remaining == 0) { + continue; } + for (int depositedIndex = 0; depositedIndex < deposited.size(); depositedIndex++) { + Candidate depositedItem = deposited.get(depositedIndex); + if (depositedItem.remaining == 0 || expectedItem.item.getType() != depositedItem.item.getType()) { + continue; + } + EnumSet differences = this.comparator.differences( + expectedItem.item, + depositedItem.item + ); + if (differences.isEmpty() || differences.contains(ReturnItemDifference.MATERIAL)) { + continue; + } + if (closest == null || differences.size() < closest.differences.size()) { + closest = new Pair(expectedIndex, depositedIndex, EnumSet.copyOf(differences)); + } + } + } + return closest; + } + + private static ReturnMismatchType mismatchType(ReturnItemDifference difference) { + return switch (difference) { + case DURABILITY -> ReturnMismatchType.DURABILITY; + case ITEM_NAME -> ReturnMismatchType.ITEM_NAME; + case ENCHANTMENTS -> ReturnMismatchType.ENCHANTMENTS; + case LORE -> ReturnMismatchType.LORE; + case NBT -> ReturnMismatchType.NBT; + case MATERIAL -> throw new IllegalArgumentException("Material differences are not attribute mismatches"); + }; + } + + private static Map totals(List items) { + Map totals = new LinkedHashMap<>(); + for (ItemStack item : items) { + totals.merge(item.getType(), item.getAmount(), Integer::sum); + } + return totals; + } + + private static List candidates(List items) { + List candidates = new ArrayList<>(items.size()); + for (ItemStack item : items) { + candidates.add(new Candidate(item)); } - return -1; + return candidates; + } + + private static void consume(Candidate expected, Candidate deposited) { + int amount = Math.min(expected.remaining, deposited.remaining); + expected.remaining -= amount; + deposited.remaining -= amount; + } + + private static final class Candidate { + private final ItemStack item; + private int remaining; + + private Candidate(ItemStack item) { + this.item = item; + this.remaining = item.getAmount(); + } + } + + private record Pair( + int expectedIndex, + int depositedIndex, + EnumSet differences + ) { } } diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java new file mode 100644 index 000000000..d4551b41b --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java @@ -0,0 +1,50 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +public record ReturnItemMismatch( + ReturnMismatchType type, + Material item, + int expectedAmount, + int depositedAmount, + Integer expectedDamage, + Integer depositedDamage +) { + + public ReturnItemMismatch { + Objects.requireNonNull(type, "Mismatch type cannot be null"); + Objects.requireNonNull(item, "Mismatch item cannot be null"); + } + + public static ReturnItemMismatch unexpected(Material item, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.UNEXPECTED_ITEM, item, 0, deposited, null, null); + } + + public static ReturnItemMismatch insufficient(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.INSUFFICIENT_AMOUNT, item, expected, deposited, null, null); + } + + public static ReturnItemMismatch excess(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.EXCESS_AMOUNT, item, expected, deposited, null, null); + } + + public static ReturnItemMismatch attribute(ReturnMismatchType type, ItemStack expected, ItemStack deposited) { + if (type == ReturnMismatchType.UNEXPECTED_ITEM + || type == ReturnMismatchType.INSUFFICIENT_AMOUNT + || type == ReturnMismatchType.EXCESS_AMOUNT) { + throw new IllegalArgumentException("Amount mismatch cannot be created as an attribute mismatch"); + } + Integer expectedDamage = type == ReturnMismatchType.DURABILITY ? damage(expected) : null; + Integer depositedDamage = type == ReturnMismatchType.DURABILITY ? damage(deposited) : null; + return new ReturnItemMismatch(type, expected.getType(), 0, 0, expectedDamage, depositedDamage); + } + + private static int damage(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + return meta instanceof Damageable damageable ? damageable.getDamage() : 0; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java new file mode 100644 index 000000000..2a47255e0 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java @@ -0,0 +1,12 @@ +package com.eternalcode.parcellockers.returns; + +public enum ReturnMismatchType { + UNEXPECTED_ITEM, + INSUFFICIENT_AMOUNT, + EXCESS_AMOUNT, + DURABILITY, + ITEM_NAME, + ENCHANTMENTS, + LORE, + NBT +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java index 5c47b0a2b..ff8c67034 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java @@ -1,20 +1,26 @@ package com.eternalcode.parcellockers.returns; +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; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; +import java.util.EnumSet; import java.util.List; -import java.util.function.BiPredicate; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; import org.junit.jupiter.api.Test; class ParcelReturnValidatorTest { - /** Equivalence by material only — the real attribute logic is tested in ReturnItemEquivalenceTest. */ - private static final BiPredicate BY_MATERIAL = (a, b) -> a.getType() == b.getType(); + private static final ReturnItemComparator BY_MATERIAL = (expected, deposited) -> + expected.getType() == deposited.getType() + ? EnumSet.noneOf(ReturnItemDifference.class) + : EnumSet.of(ReturnItemDifference.MATERIAL); private final ParcelReturnValidator validator = new ParcelReturnValidator(BY_MATERIAL); @@ -25,58 +31,114 @@ private static ItemStack item(Material type, int amount) { return item; } + private static ItemStack damagedItem(Material type, int amount, int damage) { + ItemStack item = item(type, amount); + ItemMeta meta = mock(ItemMeta.class, withSettings().extraInterfaces(Damageable.class)); + when(((Damageable) meta).getDamage()).thenReturn(damage); + when(item.getItemMeta()).thenReturn(meta); + return item; + } + @Test void exactSameStacksMatch() { List expected = List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 64)); List deposited = List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 64)); - assertTrue(this.validator.matches(deposited, expected)); - } - @Test - void splitStacksStillMatch() { - List expected = List.of(item(Material.OAK_LOG, 64)); - List deposited = List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)); + assertTrue(this.validator.validate(deposited, expected).matches()); assertTrue(this.validator.matches(deposited, expected)); } @Test - void mergedStacksStillMatch() { - List expected = List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)); - List deposited = List.of(item(Material.OAK_LOG, 64)); - assertTrue(this.validator.matches(deposited, expected)); + void splitAndMergedStacksStillMatch() { + assertTrue(this.validator.matches( + List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)), + List.of(item(Material.OAK_LOG, 64)) + )); + assertTrue(this.validator.matches( + List.of(item(Material.OAK_LOG, 64)), + List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)) + )); } @Test - void missingAmountFails() { - List expected = List.of(item(Material.DIAMOND, 5)); - List deposited = List.of(item(Material.DIAMOND, 4)); - assertFalse(this.validator.matches(deposited, expected)); + void reportsInsufficientAmountAndUnexpectedMaterialWithValues() { + ParcelReturnValidationResult result = this.validator.validate( + List.of(item(Material.DIAMOND, 4), item(Material.DIRT, 2)), + List.of(item(Material.DIAMOND, 5)) + ); + + assertFalse(result.matches()); + assertEquals(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.unexpected(Material.DIRT, 2) + ), result.mismatches()); } @Test - void extraAmountFails() { - List expected = List.of(item(Material.DIAMOND, 5)); - List deposited = List.of(item(Material.DIAMOND, 6)); - assertFalse(this.validator.matches(deposited, expected)); + void reportsExcessAmountForExpectedMaterial() { + ParcelReturnValidationResult result = this.validator.validate( + List.of(item(Material.DIAMOND, 6)), + List.of(item(Material.DIAMOND, 5)) + ); + + assertEquals(List.of(ReturnItemMismatch.excess(Material.DIAMOND, 5, 6)), result.mismatches()); } @Test - void wrongTypeFails() { - List expected = List.of(item(Material.DIAMOND, 5)); - List deposited = List.of(item(Material.EMERALD, 5)); - assertFalse(this.validator.matches(deposited, expected)); + void emptyDepositReportsEveryMissingMaterialInExpectedOrder() { + ParcelReturnValidationResult result = this.validator.validate( + List.of(), + List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 12)) + ); + + assertEquals(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 0), + ReturnItemMismatch.insufficient(Material.OAK_LOG, 12, 0) + ), result.mismatches()); } @Test - void extraForeignItemFails() { - List expected = List.of(item(Material.DIAMOND, 5)); - List deposited = List.of(item(Material.DIAMOND, 5), item(Material.DIRT, 1)); - assertFalse(this.validator.matches(deposited, expected)); + void reportsAllAttributeDifferencesForTheAffectedItem() { + ItemStack expected = damagedItem(Material.DIAMOND_SWORD, 1, 4); + ItemStack deposited = damagedItem(Material.DIAMOND_SWORD, 1, 27); + ReturnItemComparator attributes = (left, right) -> EnumSet.of( + ReturnItemDifference.DURABILITY, + ReturnItemDifference.ENCHANTMENTS + ); + + ParcelReturnValidationResult result = new ParcelReturnValidator(attributes) + .validate(List.of(deposited), List.of(expected)); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.DURABILITY, expected, deposited), + ReturnItemMismatch.attribute(ReturnMismatchType.ENCHANTMENTS, expected, deposited) + ), result.mismatches()); } @Test - void emptyDepositAgainstNonEmptyContentFails() { - List expected = List.of(item(Material.DIAMOND, 5)); - assertFalse(this.validator.matches(List.of(), expected)); + void consumesExactVariantBeforeDiagnosingClosestVariant() { + ItemStack expectedNamed = item(Material.DIAMOND_SWORD, 1); + ItemStack expectedEnchanted = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedExact = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedChangedName = item(Material.DIAMOND_SWORD, 1); + + ReturnItemComparator variants = (expected, deposited) -> { + if (expected == expectedEnchanted && deposited == depositedExact) { + return EnumSet.noneOf(ReturnItemDifference.class); + } + if (expected == expectedNamed && deposited == depositedChangedName) { + return EnumSet.of(ReturnItemDifference.ITEM_NAME); + } + return EnumSet.of(ReturnItemDifference.ITEM_NAME, ReturnItemDifference.ENCHANTMENTS); + }; + + ParcelReturnValidationResult result = new ParcelReturnValidator(variants).validate( + List.of(depositedExact, depositedChangedName), + List.of(expectedNamed, expectedEnchanted) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.ITEM_NAME, expectedNamed, depositedChangedName) + ), result.mismatches()); } } From 29a66ff620d34bc5baaad2d08e2195eb4910a153 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:00:52 +0200 Subject: [PATCH 26/29] feat: format return mismatch reasons --- .../implementation/MessageConfig.java | 11 +++- .../returns/ReturnMismatchFormatter.java | 50 +++++++++++++++++ .../returns/ReturnMismatchFormatterTest.java | 55 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java create mode 100644 src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index 87cda1f19..530ddc054 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -134,9 +134,18 @@ public static class ParcelMessages extends OkaeriConfig { .sound(SoundEventKeys.ENTITY_VILLAGER_NO) .build(); public Notice returnItemsMismatch = Notice.builder() - .chat("&4✘ &cThe deposited items do not match the original parcel contents!") + .chat("&4✘ &cThe deposited items do not match the original parcel contents!", "{MISMATCHES}") .sound(SoundEventKeys.ENTITY_VILLAGER_NO) .build(); + public String returnMismatchSeparator = ""; + public String returnMismatchUnexpectedItem = "&8- &f{ITEM}: &cunexpected item (deposited {DEPOSITED_AMOUNT})"; + public String returnMismatchInsufficientAmount = "&8- &f{ITEM}: &cinsufficient amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; + public String returnMismatchExcessAmount = "&8- &f{ITEM}: &cexcess amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; + public String returnMismatchDurability = "&8- &f{ITEM}: &cdurability differs (expected damage {EXPECTED_DAMAGE}, deposited {DEPOSITED_DAMAGE})"; + public String returnMismatchItemName = "&8- &f{ITEM}: &ccustom name differs"; + public String returnMismatchEnchantments = "&8- &f{ITEM}: &cenchantments differ"; + public String returnMismatchLore = "&8- &f{ITEM}: &clore differs"; + public String returnMismatchNbt = "&8- &f{ITEM}: &cother item data differs"; public Notice returnWindowExpired = Notice.builder() .chat("&4✘ &cThe return window for this parcel has expired!") .sound(SoundEventKeys.ENTITY_VILLAGER_NO) diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java new file mode 100644 index 000000000..3c3d81539 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java @@ -0,0 +1,50 @@ +package com.eternalcode.parcellockers.returns; + +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.util.MaterialUtil; +import java.util.Objects; +import java.util.stream.Collectors; + +public class ReturnMismatchFormatter { + + private final MessageConfig.ParcelMessages messages; + + public ReturnMismatchFormatter(MessageConfig.ParcelMessages messages) { + this.messages = Objects.requireNonNull(messages, "Parcel messages cannot be null"); + } + + public String format(ParcelReturnValidationResult result) { + Objects.requireNonNull(result, "Validation result cannot be null"); + if (result.matches()) { + throw new IllegalArgumentException("Matching return has no mismatch reasons"); + } + return result.mismatches().stream() + .map(this::format) + .collect(Collectors.joining(this.messages.returnMismatchSeparator)); + } + + private String format(ReturnItemMismatch mismatch) { + String template = switch (mismatch.type()) { + case UNEXPECTED_ITEM -> this.messages.returnMismatchUnexpectedItem; + case INSUFFICIENT_AMOUNT -> this.messages.returnMismatchInsufficientAmount; + case EXCESS_AMOUNT -> this.messages.returnMismatchExcessAmount; + case DURABILITY -> this.messages.returnMismatchDurability; + case ITEM_NAME -> this.messages.returnMismatchItemName; + case ENCHANTMENTS -> this.messages.returnMismatchEnchantments; + case LORE -> this.messages.returnMismatchLore; + case NBT -> this.messages.returnMismatchNbt; + }; + + String formatted = template + .replace("{ITEM}", MaterialUtil.format(mismatch.item())) + .replace("{EXPECTED_AMOUNT}", Integer.toString(mismatch.expectedAmount())) + .replace("{DEPOSITED_AMOUNT}", Integer.toString(mismatch.depositedAmount())); + if (mismatch.expectedDamage() != null) { + formatted = formatted.replace("{EXPECTED_DAMAGE}", mismatch.expectedDamage().toString()); + } + if (mismatch.depositedDamage() != null) { + formatted = formatted.replace("{DEPOSITED_DAMAGE}", mismatch.depositedDamage().toString()); + } + return formatted; + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java new file mode 100644 index 000000000..9f83e4fab --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java @@ -0,0 +1,55 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import java.util.List; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +class ReturnMismatchFormatterTest { + + @Test + void formatsEveryConfiguredReasonWithItemSpecificValues() { + MessageConfig.ParcelMessages messages = new MessageConfig.ParcelMessages(); + messages.returnMismatchSeparator = " | "; + messages.returnMismatchUnexpectedItem = "unexpected:{ITEM}:{DEPOSITED_AMOUNT}"; + messages.returnMismatchInsufficientAmount = "insufficient:{ITEM}:{EXPECTED_AMOUNT}/{DEPOSITED_AMOUNT}"; + messages.returnMismatchExcessAmount = "excess:{ITEM}:{EXPECTED_AMOUNT}/{DEPOSITED_AMOUNT}"; + messages.returnMismatchDurability = "durability:{ITEM}:{EXPECTED_DAMAGE}/{DEPOSITED_DAMAGE}"; + messages.returnMismatchItemName = "name:{ITEM}"; + messages.returnMismatchEnchantments = "enchantments:{ITEM}"; + messages.returnMismatchLore = "lore:{ITEM}"; + messages.returnMismatchNbt = "nbt:{ITEM}"; + ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(messages); + + String formatted = formatter.format(new ParcelReturnValidationResult(List.of( + ReturnItemMismatch.unexpected(Material.DIRT, 2), + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.excess(Material.OAK_LOG, 64, 65), + new ReturnItemMismatch(ReturnMismatchType.DURABILITY, Material.DIAMOND_SWORD, 0, 0, 4, 27), + new ReturnItemMismatch(ReturnMismatchType.ITEM_NAME, Material.WRITTEN_BOOK, 0, 0, null, null), + new ReturnItemMismatch(ReturnMismatchType.ENCHANTMENTS, Material.BOW, 0, 0, null, null), + new ReturnItemMismatch(ReturnMismatchType.LORE, Material.PAPER, 0, 0, null, null), + new ReturnItemMismatch(ReturnMismatchType.NBT, Material.SHULKER_BOX, 0, 0, null, null) + ))); + + assertEquals( + "unexpected:Dirt:2 | insufficient:Diamond:5/4 | excess:Oak log:64/65" + + " | durability:Diamond sword:4/27 | name:Written book | enchantments:Bow" + + " | lore:Paper | nbt:Shulker box", + formatted + ); + } + + @Test + void rejectsMatchingResultBecauseThereAreNoReasonsToFormat() { + ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(new MessageConfig.ParcelMessages()); + + assertThrows( + IllegalArgumentException.class, + () -> formatter.format(new ParcelReturnValidationResult(List.of())) + ); + } +} From ccc984a6b6be1eeba4a8c2e7af69a01e0927ed53 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:09:05 +0200 Subject: [PATCH 27/29] feat: explain parcel return mismatches --- .../parcellockers/ParcelLockers.java | 3 + .../gui/implementation/locker/LockerGui.java | 3 +- .../locker/ReturnDepositGui.java | 39 +++++---- .../gui/implementation/locker/ReturnGui.java | 9 +-- .../returns/ParcelReturnService.java | 22 ++++- .../locker/ReturnDepositGuiTest.java | 80 +++++++++++++++++++ .../implementation/locker/ReturnGuiTest.java | 2 +- .../returns/ParcelReturnServiceTest.java | 47 +++++++---- 8 files changed, 163 insertions(+), 42 deletions(-) create mode 100644 src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 4eaea7479..12ef2ba9b 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -47,6 +47,7 @@ import com.eternalcode.parcellockers.returns.ParcelReturnService; import com.eternalcode.parcellockers.returns.ParcelReturnValidator; import com.eternalcode.parcellockers.returns.ReturnItemEquivalence; +import com.eternalcode.parcellockers.returns.ReturnMismatchFormatter; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepositoryOrmLite; import com.eternalcode.parcellockers.returns.task.ReturnWindowPurgeTask; @@ -163,6 +164,7 @@ public void onEnable() { ); ParcelReturnValidator returnValidator = new ParcelReturnValidator(new ReturnItemEquivalence(config.settings.returnChecks)); + ReturnMismatchFormatter returnMismatchFormatter = new ReturnMismatchFormatter(messageConfig.parcel); ParcelReturnService parcelReturnService = new ParcelReturnService( parcelService, parcelContentManager, @@ -170,6 +172,7 @@ public void onEnable() { deliveryManager, lockerManager, returnValidator, + returnMismatchFormatter, parcelReturnRepository, scheduler, config, diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java index c5338bc6f..d517651d0 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java @@ -57,8 +57,7 @@ public void show(Player player, UUID entryLocker) { this.guiSettings, this.scheduler, this.guiManager, - this.miniMessage, - this.noticeService + this.miniMessage ); gui.setItem(21, this.guiSettings.parcelLockerCollectItem.toGuiItem(event -> collectionGui.show(player))); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java index 85e0e8610..5b248dea4 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java @@ -4,7 +4,6 @@ import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; import com.eternalcode.parcellockers.gui.GuiManager; -import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import dev.triumphteam.gui.guis.Gui; import dev.triumphteam.gui.guis.GuiItem; @@ -12,7 +11,9 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; import java.util.stream.IntStream; +import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -28,23 +29,40 @@ public class ReturnDepositGui { private final GuiSettings guiSettings; private final MiniMessage miniMessage; private final GuiManager guiManager; - private final NoticeService noticeService; private final Parcel parcel; + private final BiFunction guiFactory; public ReturnDepositGui( Scheduler scheduler, GuiSettings guiSettings, MiniMessage miniMessage, GuiManager guiManager, - NoticeService noticeService, Parcel parcel + ) { + this( + scheduler, + guiSettings, + miniMessage, + guiManager, + parcel, + (title, rows) -> Gui.storage().title(title).rows(rows).create() + ); + } + + ReturnDepositGui( + Scheduler scheduler, + GuiSettings guiSettings, + MiniMessage miniMessage, + GuiManager guiManager, + Parcel parcel, + BiFunction guiFactory ) { this.scheduler = scheduler; this.guiSettings = guiSettings; this.miniMessage = miniMessage; this.guiManager = guiManager; - this.noticeService = noticeService; this.parcel = parcel; + this.guiFactory = guiFactory; } void show(Player player) { @@ -54,10 +72,8 @@ void show(Player player) { case LARGE -> 4; }; - StorageGui gui = Gui.storage() - .title(this.miniMessage.deserialize(this.guiSettings.parcelReturnDepositGuiTitle)) - .rows(rows) - .create(); + Component title = this.miniMessage.deserialize(this.guiSettings.parcelReturnDepositGuiTitle); + StorageGui gui = this.guiFactory.apply(title, rows); GuiItem backgroundItem = this.guiSettings.mainGuiBackgroundItem.toGuiItem(event -> event.setCancelled(true)); IntStream.rangeClosed(1, 9).forEach(i -> gui.setItem(gui.getRows(), i, backgroundItem)); @@ -68,11 +84,6 @@ void show(Player player) { event.setCancelled(true); List deposited = this.takeDepositedItems(gui); - if (deposited.isEmpty()) { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returnItemsMismatch); - return; - } - confirmed.set(true); gui.close(player); this.guiManager.returnParcel(player, this.parcel, deposited); @@ -83,7 +94,6 @@ void show(Player player) { if (confirmed.get()) { return; } - // Closed without confirming: give every deposited stack back. List leftovers = this.takeDepositedItems(gui); this.scheduler.run(() -> leftovers.forEach(item -> ItemUtil.giveItem(player, item))); }); @@ -91,7 +101,6 @@ void show(Player player) { this.scheduler.run(() -> gui.open(player)); } - /** Snapshots and clears the deposit slots (everything above the bottom control row). */ private List takeDepositedItems(StorageGui gui) { ItemStack[] contents = gui.getInventory().getContents(); List items = new ArrayList<>(); diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java index 4bfe02d19..d387d35d0 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java @@ -7,7 +7,6 @@ import com.eternalcode.parcellockers.gui.GuiManager; import com.eternalcode.parcellockers.gui.GuiView; import com.eternalcode.parcellockers.gui.PaginatedGuiRefresher; -import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.util.PlaceholderUtil; import com.eternalcode.parcellockers.shared.Page; @@ -39,22 +38,19 @@ public class ReturnGui implements GuiView { private final Scheduler scheduler; private final GuiManager guiManager; private final MiniMessage miniMessage; - private final NoticeService noticeService; private final Function guiFactory; public ReturnGui( GuiSettings guiSettings, Scheduler scheduler, GuiManager guiManager, - MiniMessage miniMessage, - NoticeService noticeService + MiniMessage miniMessage ) { this( guiSettings, scheduler, guiManager, miniMessage, - noticeService, title -> Gui.paginated() .rows(6) .disableAllInteractions() @@ -68,14 +64,12 @@ public ReturnGui( Scheduler scheduler, GuiManager guiManager, MiniMessage miniMessage, - NoticeService noticeService, Function guiFactory ) { this.guiSettings = guiSettings; this.scheduler = scheduler; this.guiManager = guiManager; this.miniMessage = miniMessage; - this.noticeService = noticeService; this.guiFactory = guiFactory; } @@ -181,7 +175,6 @@ private CompletableFuture> createParcelItemAsync( this.guiSettings, this.miniMessage, this.guiManager, - this.noticeService, parcel ).show(player)); }; diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index 21aa82021..40f6bde87 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -50,6 +50,7 @@ public class ParcelReturnService { private final DeliveryManager deliveryManager; private final LockerManager lockerManager; private final ParcelReturnValidator validator; + private final ReturnMismatchFormatter mismatchFormatter; private final ParcelReturnRepository parcelReturnRepository; private final Scheduler scheduler; private final PluginConfig config; @@ -64,6 +65,7 @@ public ParcelReturnService( DeliveryManager deliveryManager, LockerManager lockerManager, ParcelReturnValidator validator, + ReturnMismatchFormatter mismatchFormatter, ParcelReturnRepository parcelReturnRepository, Scheduler scheduler, PluginConfig config, @@ -77,6 +79,7 @@ public ParcelReturnService( this.deliveryManager = deliveryManager; this.lockerManager = lockerManager; this.validator = validator; + this.mismatchFormatter = mismatchFormatter; this.parcelReturnRepository = parcelReturnRepository; this.scheduler = scheduler; this.config = config; @@ -124,8 +127,9 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List messages.parcel.cannotReturn); } - if (!this.validator.matches(deposited, optionalContent.get().items())) { - return this.abort(player, deposited, messages -> messages.parcel.returnItemsMismatch); + ParcelReturnValidationResult validation = this.validator.validate(deposited, optionalContent.get().items()); + if (!validation.matches()) { + return this.abortMismatch(player, deposited, validation); } // The return ships to the original entry locker. It may have been deleted @@ -265,6 +269,20 @@ private CompletableFuture abort(Player player, List deposited, return CompletableFuture.completedFuture(null); } + private CompletableFuture abortMismatch( + Player player, + List deposited, + ParcelReturnValidationResult validation + ) { + this.giveBack(player, deposited); + this.noticeService.create() + .notice(messages -> messages.parcel.returnItemsMismatch) + .player(player.getUniqueId()) + .placeholder("{MISMATCHES}", this.mismatchFormatter.format(validation)) + .send(); + return CompletableFuture.completedFuture(null); + } + private void giveBack(Player player, List items) { this.scheduler.run(() -> items.forEach(item -> ItemUtil.giveItem(player, item))); } diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java new file mode 100644 index 000000000..ae648e88a --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java @@ -0,0 +1,80 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import dev.triumphteam.gui.TriumphGui; +import dev.triumphteam.gui.components.GuiAction; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.StorageGui; +import java.lang.reflect.Field; +import java.util.List; +import java.util.UUID; +import java.util.function.BiFunction; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ReturnDepositGuiTest { + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void emptyConfirmationUsesNormalReturnValidationFlow() throws ReflectiveOperationException { + Scheduler scheduler = mock(Scheduler.class); + GuiSettings settings = mock(GuiSettings.class); + settings.parcelReturnDepositGuiTitle = "Return items"; + settings.mainGuiBackgroundItem = mock(ConfigItem.class); + settings.confirmReturnItem = mock(ConfigItem.class); + MiniMessage miniMessage = mock(MiniMessage.class); + when(miniMessage.deserialize("Return items")).thenReturn(Component.empty()); + GuiManager guiManager = mock(GuiManager.class); + Player player = mock(Player.class); + Parcel parcel = new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "parcel", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED + ); + + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + Field serverField = Bukkit.class.getDeclaredField("server"); + serverField.setAccessible(true); + serverField.set(null, server); + TriumphGui.init(mock(Plugin.class)); + + StorageGui gui = mock(StorageGui.class); + when(gui.getRows()).thenReturn(2); + Inventory inventory = mock(Inventory.class); + when(inventory.getContents()).thenReturn(new ItemStack[18]); + when(gui.getInventory()).thenReturn(inventory); + when(settings.mainGuiBackgroundItem.toGuiItem(any())).thenReturn(mock(GuiItem.class)); + ArgumentCaptor> confirmAction = ArgumentCaptor.forClass(GuiAction.class); + when(settings.confirmReturnItem.toGuiItem(confirmAction.capture())).thenReturn(mock(GuiItem.class)); + BiFunction guiFactory = (title, rows) -> gui; + + new ReturnDepositGui(scheduler, settings, miniMessage, guiManager, parcel, guiFactory).show(player); + InventoryClickEvent click = mock(InventoryClickEvent.class); + confirmAction.getValue().execute(click); + + verify(click).setCancelled(true); + verify(gui).close(player); + verify(guiManager).returnParcel(player, parcel, List.of()); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java index ec6450ba1..fe9b67fe9 100644 --- a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java @@ -66,7 +66,7 @@ void emptyResultMutatesGuiOnlyInsideScheduledMainThreadCallback() throws Reflect Function guiFactory = ignored -> gui; ReturnGui returnGui = new ReturnGui( - settings, scheduler, guiManager, miniMessage, noticeService, guiFactory); + settings, scheduler, guiManager, miniMessage, guiFactory); returnGui.show(player); clearInvocations(gui, scheduler); diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index 55415314b..12b5167fe 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -6,12 +6,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_SELF; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.NoticeBroadcast; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; import com.eternalcode.parcellockers.content.ParcelContent; import com.eternalcode.parcellockers.content.ParcelContentManager; @@ -32,6 +34,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import net.milkbowl.vault.economy.Economy; +import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -111,7 +114,6 @@ void capturesFeeBypassPermissionBeforeRepositoryContinuation() { @Test void appliesDefaultPrefixToReturnedParcelName() { Fixture fixture = new Fixture(); - assertEquals("[Refund] parcel", fixture.returnedParcelName()); } @@ -119,7 +121,6 @@ void appliesDefaultPrefixToReturnedParcelName() { void appliesCustomReturnedParcelNameFormat() { Fixture fixture = new Fixture(); fixture.config.settings.parcelReturnNameFormat = "Returned: {NAME} / {NAME}"; - assertEquals("Returned: parcel / parcel", fixture.returnedParcelName()); } @@ -127,10 +128,33 @@ void appliesCustomReturnedParcelNameFormat() { void usesLiteralReturnedParcelNameWhenFormatHasNoPlaceholder() { Fixture fixture = new Fixture(); fixture.config.settings.parcelReturnNameFormat = "Returned parcel"; - assertEquals("Returned parcel", fixture.returnedParcelName()); } + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + void reportsAllMismatchReasonsAndStopsReturnProcessing() { + Fixture fixture = new Fixture(); + fixture.validReturn(); + ParcelReturnValidationResult mismatch = new ParcelReturnValidationResult(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.unexpected(Material.DIRT, 2) + )); + when(fixture.validator.validate(fixture.deposited, fixture.deposited)).thenReturn(mismatch); + when(fixture.mismatchFormatter.format(mismatch)).thenReturn("firstsecond"); + NoticeBroadcast broadcast = mock(NoticeBroadcast.class, RETURNS_SELF); + when(fixture.noticeService.create()).thenReturn(broadcast); + + fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited).join(); + + verify(broadcast).placeholder("{MISMATCHES}", "firstsecond"); + verify(broadcast).send(); + verify(fixture.scheduler).run(any(Runnable.class)); + verify(fixture.returnRepository, never()).commit(any(), any(), any()); + verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); + } + private static final class Fixture { private final UUID playerId = UUID.randomUUID(); @@ -141,6 +165,7 @@ private static final class Fixture { private final DeliveryManager deliveryManager = mock(DeliveryManager.class); private final LockerManager lockerManager = mock(LockerManager.class); private final ParcelReturnValidator validator = mock(ParcelReturnValidator.class); + private final ReturnMismatchFormatter mismatchFormatter = mock(ReturnMismatchFormatter.class); private final ParcelReturnRepository returnRepository = mock(ParcelReturnRepository.class); private final Scheduler scheduler = mock(Scheduler.class); private final NoticeService noticeService = mock(NoticeService.class); @@ -151,16 +176,8 @@ private static final class Fixture { private final ItemStack item = mock(ItemStack.class); private final List deposited = List.of(this.item); private final Parcel parcel = new Parcel( - UUID.randomUUID(), - UUID.randomUUID(), - "parcel", - "description", - false, - this.playerId, - ParcelSize.SMALL, - UUID.randomUUID(), - UUID.randomUUID(), - ParcelStatus.COLLECTED + UUID.randomUUID(), UUID.randomUUID(), "parcel", "description", false, + this.playerId, ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED ); private final ParcelReturnService service; @@ -177,6 +194,7 @@ private Fixture() { this.deliveryManager, this.lockerManager, this.validator, + this.mismatchFormatter, this.returnRepository, this.scheduler, this.config, @@ -195,7 +213,8 @@ private void validReturn() { new CollectedParcel(this.parcel.uuid(), Instant.now())))); when(this.contentManager.get(this.parcel.uuid())) .thenReturn(CompletableFuture.completedFuture(Optional.of(content))); - when(this.validator.matches(this.deposited, this.deposited)).thenReturn(true); + when(this.validator.validate(this.deposited, this.deposited)) + .thenReturn(new ParcelReturnValidationResult(List.of())); when(this.lockerManager.get(this.parcel.entryLocker())) .thenReturn(CompletableFuture.completedFuture(Optional.of( new Locker(this.parcel.entryLocker(), "Entry", null)))); From ce46067d94ffe27af50d7676cfcca72872e0baf0 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:15:19 +0200 Subject: [PATCH 28/29] fix: append mismatch details for existing configs --- .../implementation/MessageConfig.java | 2 +- .../returns/ParcelReturnService.java | 2 +- .../returns/ReturnMismatchFormatter.java | 29 +++++++++++++++++-- .../returns/ParcelReturnServiceTest.java | 4 +++ .../returns/ReturnMismatchFormatterTest.java | 17 +++++++++++ 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index 530ddc054..a06d6d812 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -134,7 +134,7 @@ public static class ParcelMessages extends OkaeriConfig { .sound(SoundEventKeys.ENTITY_VILLAGER_NO) .build(); public Notice returnItemsMismatch = Notice.builder() - .chat("&4✘ &cThe deposited items do not match the original parcel contents!", "{MISMATCHES}") + .chat("&4✘ &cThe deposited items do not match the original parcel contents!") .sound(SoundEventKeys.ENTITY_VILLAGER_NO) .build(); public String returnMismatchSeparator = ""; diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index 40f6bde87..daf553752 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -276,7 +276,7 @@ private CompletableFuture abortMismatch( ) { this.giveBack(player, deposited); this.noticeService.create() - .notice(messages -> messages.parcel.returnItemsMismatch) + .notice(this.mismatchFormatter.notice(validation)) .player(player.getUniqueId()) .placeholder("{MISMATCHES}", this.mismatchFormatter.format(validation)) .send(); diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java index 3c3d81539..4c21ea4db 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java @@ -1,5 +1,7 @@ package com.eternalcode.parcellockers.returns; +import com.eternalcode.multification.notice.Notice; +import com.eternalcode.multification.notice.resolver.chat.ChatContent; import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; import com.eternalcode.parcellockers.util.MaterialUtil; import java.util.Objects; @@ -14,13 +16,34 @@ public ReturnMismatchFormatter(MessageConfig.ParcelMessages messages) { } public String format(ParcelReturnValidationResult result) { + requireMismatch(result); + return result.mismatches().stream() + .map(this::format) + .collect(Collectors.joining(this.messages.returnMismatchSeparator)); + } + + public Notice notice(ParcelReturnValidationResult result) { + requireMismatch(result); + Notice.Builder builder = Notice.builder(); + boolean containsPlaceholder = false; + for (var part : this.messages.returnItemsMismatch.parts()) { + builder.withPart(part); + if (part.content() instanceof ChatContent chat + && chat.messages().stream().anyMatch(message -> message.contains("{MISMATCHES}"))) { + containsPlaceholder = true; + } + } + if (!containsPlaceholder) { + builder.chat("{MISMATCHES}"); + } + return builder.build(); + } + + private static void requireMismatch(ParcelReturnValidationResult result) { Objects.requireNonNull(result, "Validation result cannot be null"); if (result.matches()) { throw new IllegalArgumentException("Matching return has no mismatch reasons"); } - return result.mismatches().stream() - .map(this::format) - .collect(Collectors.joining(this.messages.returnMismatchSeparator)); } private String format(ReturnItemMismatch mismatch) { diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index 12b5167fe..a152ee176 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -13,6 +13,7 @@ import static org.mockito.Mockito.when; import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.Notice; import com.eternalcode.multification.notice.NoticeBroadcast; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; import com.eternalcode.parcellockers.content.ParcelContent; @@ -142,11 +143,14 @@ void reportsAllMismatchReasonsAndStopsReturnProcessing() { )); when(fixture.validator.validate(fixture.deposited, fixture.deposited)).thenReturn(mismatch); when(fixture.mismatchFormatter.format(mismatch)).thenReturn("firstsecond"); + Notice mismatchNotice = mock(Notice.class); + when(fixture.mismatchFormatter.notice(mismatch)).thenReturn(mismatchNotice); NoticeBroadcast broadcast = mock(NoticeBroadcast.class, RETURNS_SELF); when(fixture.noticeService.create()).thenReturn(broadcast); fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited).join(); + verify(broadcast).notice(mismatchNotice); verify(broadcast).placeholder("{MISMATCHES}", "firstsecond"); verify(broadcast).send(); verify(fixture.scheduler).run(any(Runnable.class)); diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java index 9f83e4fab..d9fbf7e49 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.eternalcode.multification.notice.Notice; +import com.eternalcode.multification.notice.resolver.chat.ChatContent; import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; import java.util.List; import org.bukkit.Material; @@ -43,6 +45,21 @@ void formatsEveryConfiguredReasonWithItemSpecificValues() { ); } + @Test + void appendsMismatchPlaceholderToLegacyConfiguredNotice() { + MessageConfig.ParcelMessages messages = new MessageConfig.ParcelMessages(); + messages.returnItemsMismatch = Notice.chat("legacy mismatch message"); + ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(messages); + ParcelReturnValidationResult mismatch = new ParcelReturnValidationResult(List.of( + ReturnItemMismatch.unexpected(Material.DIRT, 2) + )); + + Notice notice = formatter.notice(mismatch); + ChatContent chat = (ChatContent) notice.parts().getFirst().content(); + + assertEquals(List.of("legacy mismatch message", "{MISMATCHES}"), chat.messages()); + } + @Test void rejectsMatchingResultBecauseThereAreNoReasonsToFormat() { ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(new MessageConfig.ParcelMessages()); From 287cabb3901c4e34c5e6e37c25dfa065f50e95f3 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:21:34 +0200 Subject: [PATCH 29/29] test: strengthen parcel return diagnostics coverage --- build.gradle.kts | 1 + .../locker/ReturnDepositGuiTest.java | 35 ++++++++-- .../implementation/locker/ReturnGuiTest.java | 34 ++++++++-- .../returns/ParcelReturnServiceTest.java | 11 ++- .../returns/ParcelReturnValidatorTest.java | 67 +++++++++++++++++++ 5 files changed, 134 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c29042eba..f72cc4a40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { testImplementation("mysql:mysql-connector-java:${Versions.MYSQL_CONNECTOR}") testImplementation("de.eldoria.jacksonbukkit:paper:${Versions.JACKSON_BUKKIT}") testImplementation("com.eternalcode:eternalcode-commons-adventure:${Versions.ETERNALCODE_COMMONS}") + testImplementation("com.eternalcode:eternalcode-commons-bukkit:${Versions.ETERNALCODE_COMMONS}") testImplementation("com.eternalcode:eternalcode-commons-shared:${Versions.ETERNALCODE_COMMONS}") testImplementation("com.eternalcode:multification-bukkit:${Versions.MULTIFICATION}") testImplementation("com.github.MilkBowl:VaultAPI:${Versions.VAULT_API}") diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java index ae648e88a..0deb52874 100644 --- a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java @@ -30,11 +30,39 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; class ReturnDepositGuiTest { + private Field serverField; + private Field triumphPluginField; + private Object previousServer; + private Object previousTriumphPlugin; + + @BeforeEach + void installGuiEnvironment() throws ReflectiveOperationException { + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + this.serverField = Bukkit.class.getDeclaredField("server"); + this.serverField.setAccessible(true); + this.previousServer = this.serverField.get(null); + this.serverField.set(null, server); + + this.triumphPluginField = TriumphGui.class.getDeclaredField("PLUGIN"); + this.triumphPluginField.setAccessible(true); + this.previousTriumphPlugin = this.triumphPluginField.get(null); + TriumphGui.init(mock(Plugin.class)); + } + + @AfterEach + void restoreGuiEnvironment() throws ReflectiveOperationException { + this.serverField.set(null, this.previousServer); + this.triumphPluginField.set(null, this.previousTriumphPlugin); + } + @SuppressWarnings({"unchecked", "rawtypes"}) @Test void emptyConfirmationUsesNormalReturnValidationFlow() throws ReflectiveOperationException { @@ -52,13 +80,6 @@ void emptyConfirmationUsesNormalReturnValidationFlow() throws ReflectiveOperatio UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED ); - Server server = mock(Server.class); - when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); - Field serverField = Bukkit.class.getDeclaredField("server"); - serverField.setAccessible(true); - serverField.set(null, server); - TriumphGui.init(mock(Plugin.class)); - StorageGui gui = mock(StorageGui.class); when(gui.getRows()).thenReturn(2); Inventory inventory = mock(Inventory.class); diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java index fe9b67fe9..420036503 100644 --- a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java @@ -29,11 +29,39 @@ import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; class ReturnGuiTest { + private Field serverField; + private Field triumphPluginField; + private Object previousServer; + private Object previousTriumphPlugin; + + @BeforeEach + void installGuiEnvironment() throws ReflectiveOperationException { + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + this.serverField = Bukkit.class.getDeclaredField("server"); + this.serverField.setAccessible(true); + this.previousServer = this.serverField.get(null); + this.serverField.set(null, server); + + this.triumphPluginField = TriumphGui.class.getDeclaredField("PLUGIN"); + this.triumphPluginField.setAccessible(true); + this.previousTriumphPlugin = this.triumphPluginField.get(null); + TriumphGui.init(mock(Plugin.class)); + } + + @AfterEach + void restoreGuiEnvironment() throws ReflectiveOperationException { + this.serverField.set(null, this.previousServer); + this.triumphPluginField.set(null, this.previousTriumphPlugin); + } + @Test void emptyResultMutatesGuiOnlyInsideScheduledMainThreadCallback() throws ReflectiveOperationException { GuiSettings settings = mock(GuiSettings.class); @@ -50,12 +78,6 @@ void emptyResultMutatesGuiOnlyInsideScheduledMainThreadCallback() throws Reflect GuiManager guiManager = mock(GuiManager.class); MiniMessage miniMessage = mock(MiniMessage.class); NoticeService noticeService = mock(NoticeService.class); - Server server = mock(Server.class); - when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); - Field serverField = Bukkit.class.getDeclaredField("server"); - serverField.setAccessible(true); - serverField.set(null, server); - TriumphGui.init(mock(Plugin.class)); PaginatedGui gui = mock(PaginatedGui.class); Player player = mock(Player.class); UUID playerId = UUID.randomUUID(); diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index a152ee176..1ba75c219 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -8,10 +8,12 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.RETURNS_SELF; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.eternalcode.commons.bukkit.ItemUtil; import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.multification.notice.Notice; import com.eternalcode.multification.notice.NoticeBroadcast; @@ -42,6 +44,7 @@ import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; class ParcelReturnServiceTest { @@ -153,7 +156,13 @@ void reportsAllMismatchReasonsAndStopsReturnProcessing() { verify(broadcast).notice(mismatchNotice); verify(broadcast).placeholder("{MISMATCHES}", "firstsecond"); verify(broadcast).send(); - verify(fixture.scheduler).run(any(Runnable.class)); + ArgumentCaptor giveBack = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).run(giveBack.capture()); + try (MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + giveBack.getValue().run(); + itemUtil.verify(() -> ItemUtil.giveItem(fixture.player, fixture.item)); + } + verify(fixture.returnRepository, never()).commit(any(), any(), any()); verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java index ff8c67034..29bde6592 100644 --- a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java @@ -141,4 +141,71 @@ void consumesExactVariantBeforeDiagnosingClosestVariant() { ReturnItemMismatch.attribute(ReturnMismatchType.ITEM_NAME, expectedNamed, depositedChangedName) ), result.mismatches()); } + + @Test + void choosesThePairWithTheFewestDifferences() { + ItemStack expectedNamed = item(Material.DIAMOND_SWORD, 1); + ItemStack expectedEnchanted = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedEnchanted = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedNamed = item(Material.DIAMOND_SWORD, 1); + + ReturnItemComparator variants = (expected, deposited) -> { + if (expected == expectedNamed && deposited == depositedNamed) { + return EnumSet.of(ReturnItemDifference.ITEM_NAME); + } + if (expected == expectedEnchanted && deposited == depositedEnchanted) { + return EnumSet.of(ReturnItemDifference.ENCHANTMENTS); + } + return EnumSet.of(ReturnItemDifference.ITEM_NAME, ReturnItemDifference.ENCHANTMENTS); + }; + + ParcelReturnValidationResult result = new ParcelReturnValidator(variants).validate( + List.of(depositedEnchanted, depositedNamed), + List.of(expectedNamed, expectedEnchanted) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.ITEM_NAME, expectedNamed, depositedNamed), + ReturnItemMismatch.attribute(ReturnMismatchType.ENCHANTMENTS, expectedEnchanted, depositedEnchanted) + ), result.mismatches()); + } + + @Test + void aggregatesDuplicateAttributeReasons() { + ItemStack firstExpected = item(Material.DIAMOND_SWORD, 1); + ItemStack secondExpected = item(Material.DIAMOND_SWORD, 1); + ItemStack firstDeposited = item(Material.DIAMOND_SWORD, 1); + ItemStack secondDeposited = item(Material.DIAMOND_SWORD, 1); + ReturnItemComparator enchantments = (expected, deposited) -> + EnumSet.of(ReturnItemDifference.ENCHANTMENTS); + + ParcelReturnValidationResult result = new ParcelReturnValidator(enchantments).validate( + List.of(firstDeposited, secondDeposited), + List.of(firstExpected, secondExpected) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.ENCHANTMENTS, firstExpected, firstDeposited) + ), result.mismatches()); + } + + @Test + void resolvesEqualDifferenceCountsInInputOrder() { + ItemStack firstExpected = damagedItem(Material.DIAMOND_SWORD, 1, 1); + ItemStack secondExpected = damagedItem(Material.DIAMOND_SWORD, 1, 2); + ItemStack firstDeposited = damagedItem(Material.DIAMOND_SWORD, 1, 10); + ItemStack secondDeposited = damagedItem(Material.DIAMOND_SWORD, 1, 20); + ReturnItemComparator durability = (expected, deposited) -> + EnumSet.of(ReturnItemDifference.DURABILITY); + + ParcelReturnValidationResult result = new ParcelReturnValidator(durability).validate( + List.of(firstDeposited, secondDeposited), + List.of(firstExpected, secondExpected) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.DURABILITY, firstExpected, firstDeposited), + ReturnItemMismatch.attribute(ReturnMismatchType.DURABILITY, secondExpected, secondDeposited) + ), result.mismatches()); + } }