From 0be96c44ee48f611ed60f884f6897a50f945c1a2 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:28:06 +0200 Subject: [PATCH 01/19] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2fcfe81a..1158d10c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ .codex/ .gemini/ graphify-out/ +.worktrees/ .DS_Store [Dd]esktop.ini From 6f01fb6753d5fcff485d7761d0a6be995112877c Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:32:21 +0200 Subject: [PATCH 02/19] docs: design ParcelLockers API module --- .../specs/2026-07-20-api-module-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-api-module-design.md diff --git a/docs/superpowers/specs/2026-07-20-api-module-design.md b/docs/superpowers/specs/2026-07-20-api-module-design.md new file mode 100644 index 00000000..63e7860c --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-api-module-design.md @@ -0,0 +1,162 @@ +# ParcelLockers API Module Design + +**Issue:** [#176 — Add API module](https://github.com/EternalCodeTeam/ParcelLockers/issues/176) + +**Objective:** Split ParcelLockers into a published API module and an implementation plugin module so other Paper plugins can listen to ParcelLockers events and manage parcels and lockers through stable service contracts. + +## Scope + +The first API release exposes parcel and locker operations only. It does not expose users, parcel contents, deliveries, repositories, database types, GUI types, configuration, Discord integration, Vault integration, commands, caches, or administrative bulk operations. + +External plugins may perform reads and controlled mutations through services. Every mutation continues to pass through the plugin's validation, persistence, economy, scheduling, and event behavior. No database or repository escape hatch is added. + +## Project Structure + +The Gradle build becomes a two-module build following the EternalCombat layout: + +- `parcellockers-api` is a `java-library` containing the public contracts, public models, events, public exceptions, API facade, and provider. +- `parcellockers-plugin` contains the Paper plugin entry point and every implementation detail. It depends on `implementation(project(":parcellockers-api"))`. +- The root project aggregates shared group, version, repositories, Java 21 settings, and module tasks. It contains no production source set. + +The existing package prefix `com.eternalcode.parcellockers` remains unchanged. This avoids unnecessary source incompatibility and lets the plugin entry point call package-private provider lifecycle methods. + +The plugin shadow JAR includes the API classes and remains the deployable Paper artifact. The shadow artifact continues to be written under the root `build/libs` directory so the existing `./gradlew shadowJar` workflow remains valid. + +## API Module Contents + +### Facade and provider + +`ParcelLockersApi` exposes: + +```java +ParcelService getParcelService(); +LockerService getLockerService(); +``` + +`ParcelLockersProvider.provide()` returns the initialized facade. The provider stores the facade in a `volatile` field and rejects invalid lifecycle transitions: + +- `provide()` before initialization or after deinitialization throws `IllegalStateException`. +- a second `initialize(...)` call throws `IllegalStateException`. +- `deinitialize()` without an initialized API throws `IllegalStateException`. +- `initialize(...)` and `deinitialize()` remain package-private so external consumers cannot replace the active API. + +The main `ParcelLockers` plugin class implements `ParcelLockersApi`. It initializes the provider only after both public services are ready. During `onDisable()`, it deinitializes the provider before unregistering components and closing the datasource. + +### Parcel service + +The public `ParcelService` supports: + +- sending and collecting parcels with the existing `Player` and `ItemStack` inputs; +- updating a parcel, including the conditional status update used for concurrency safety; +- deleting a parcel by object or UUID; +- retrieving a parcel by UUID; +- paginated retrieval by sender, receiver, collectible destination, return eligibility, or all parcels. + +Implementation-only methods are removed from the public contract: dispatch rollback, cache invalidation, sender-facing administrative deletion, and bulk deletion with `NoticeService`. The plugin module may define an internal extension contract for these operations where an implementation-facing type is required. + +### Locker service + +A new `LockerService` interface supports: + +- lookup by UUID or `Position`; +- paginated listing; +- creation, deletion, and rename operations; +- locker capacity checks. + +`LockerManager` implements `LockerService`. Cache-only lookup and administrative bulk deletion remain implementation details on `LockerManager` and are not part of the published contract. + +### Models, events, and exceptions + +The API module owns the types required by those contracts: + +- parcel types: `Parcel`, `ParcelStatus`, and `ParcelSize`; +- locker types: `Locker` and `Position`; +- pagination types: `Page` and `PageResult`; +- parcel events: `ParcelSendEvent`, `ParcelDeliverEvent`, `ParcelCollectEvent`, and `ParcelReturnEvent`; +- locker events: `LockerCreateEvent` and `LockerDeleteEvent`; +- event base: `CancellableEvent`; +- public exceptions: `ParcelLockersException`, `ParcelOperationException`, and `ValidationException`. + +`DatabaseException` remains in the plugin module because persistence is not public API. Locker validation stops exposing Okaeri's `ValidationException` and completes operations exceptionally with the ParcelLockers API `ValidationException` instead. + +## Dependencies and Nullness + +The API module has only these external compile contracts: + +- `io.papermc.paper:paper-api` through `compileOnlyApi`, because service signatures and events use Paper types; +- `org.jspecify:jspecify:1.0.0` through `api`. + +Public API packages are marked with JSpecify `@NullMarked` package annotations. Intentionally optional values are marked explicitly with `@Nullable`; this includes the optional destination-locker filter used by collectible-parcel queries. JetBrains Annotations is not an API dependency. + +The plugin module keeps all current runtime and integration dependencies. It may retain implementation-only annotations where needed, but public signatures use JSpecify. + +## Behavior and Threading + +All service operations retain their current `CompletableFuture` model. The module split does not introduce blocking database calls or change the established result types. + +Existing event behavior is preserved: + +- currently asynchronous parcel and locker events stay asynchronous; +- synchronous events stay synchronous; +- `LockerDeleteEvent` continues to be scheduled onto the main server thread before deletion; +- cancelling an event preserves the current operation outcome. + +Validation and operational failures complete returned futures exceptionally with public ParcelLockers exceptions where callers need to distinguish them. Low-level database failures remain implementation details and may be wrapped by the public exception hierarchy rather than exposing ORMLite, HikariCP, or Okaeri types. + +## Maven Publication + +The API is published with these coordinates: + +```text +com.eternalcode:parcellockers-api: +``` + +`parcellockers-api` applies `java-library` and `maven-publish`, and publishes its Java component plus source and Javadoc JARs. Repositories are: + +- `mavenLocal()` for local consumer testing; +- `https://repo.eternalcode.pl/releases` for releases. + +Remote credentials come only from `ETERNAL_CODE_MAVEN_USERNAME` and `ETERNAL_CODE_MAVEN_PASSWORD`, matching EternalCombat. Publication configuration must not fail local builds when those environment variables are absent; credentials are required only when the remote publish task executes. + +## Source Migration + +All current production sources and resources first move under `parcellockers-plugin`. The facade, provider, public service interfaces, public models, events, shared pagination/position types, event base, public exceptions, and their package nullness declarations then live under `parcellockers-api`. + +All existing tests move under `parcellockers-plugin` unless they test a type owned by the API module. Package names remain stable. No unrelated refactor or API expansion is part of the migration. + +## Verification + +Provider unit tests cover: + +1. `provide()` before initialization; +2. successful initialization and identity of the returned facade; +3. rejection of duplicate initialization; +4. successful deinitialization; +5. `provide()` and repeated deinitialization after shutdown. + +The implementation is verified with: + +```powershell +.\gradlew.bat :parcellockers-api:test +.\gradlew.bat :parcellockers-plugin:test +.\gradlew.bat test +.\gradlew.bat :parcellockers-api:publishToMavenLocal +.\gradlew.bat :parcellockers-plugin:shadowJar +``` + +Artifact inspection must confirm that: + +- the API JAR contains facade, provider, services, models, events, and public exceptions; +- the API JAR contains no repository implementations, GUI, configuration, database, Discord, or command classes; +- the published POM exposes Paper and JSpecify as consumer compile contracts; +- the deployable shadow JAR contains the API classes and the Paper plugin implementation; +- the existing test suite remains green after its module move. + +## Out of Scope + +- compatibility shims for unpublished internal classes; +- a user, content, delivery, Discord, GUI, repository, or database API; +- Bukkit `ServicesManager` registration in addition to the static provider; +- changing parcel or locker business rules; +- publishing the plugin module as a library; +- pushing the branch or opening a pull request. From be98f38729f89eb12325a3ad5bab087bcc2618d5 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:47:02 +0200 Subject: [PATCH 03/19] docs: plan ParcelLockers API module implementation --- .../plans/2026-07-20-api-module.md | 959 ++++++++++++++++++ 1 file changed, 959 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-api-module.md diff --git a/docs/superpowers/plans/2026-07-20-api-module.md b/docs/superpowers/plans/2026-07-20-api-module.md new file mode 100644 index 00000000..bb71d49c --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-api-module.md @@ -0,0 +1,959 @@ +# ParcelLockers API Module 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:** Split ParcelLockers into a published API module and a deployable Paper plugin module, exposing stable parcel and locker services through a lifecycle-safe provider. + +**Architecture:** `parcellockers-api` owns the facade, provider, service contracts, models, events, shared API values, and public exceptions. `parcellockers-plugin` depends on that module and owns every implementation, repository, integration, command, and GUI; its main class implements the API facade and initializes the provider only after startup succeeds. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Paper API 1.21.11, JSpecify 1.0.0, JUnit 6, Mockito 5, Gradle Maven Publish, Shadow 9.6. + +## Global Constraints + +- Work only on branch `codex/issue-176-api-module` in `D:\Projekty\ParcelLockers\.worktrees\issue-176-api-module`. +- Keep the package prefix `com.eternalcode.parcellockers` unchanged. +- Keep Java source and target compatibility at Java 21. +- Use `parcellockers-api` and `parcellockers-plugin` as the two module names. +- Keep the deployable shadow JAR under root `build/libs` and preserve `./gradlew shadowJar`. +- The API may depend only on Paper through `compileOnlyApi` and JSpecify `1.0.0` through `api`, plus test-only dependencies. +- Expose parcel and locker operations only; do not expose users, content, delivery, repositories, database, configuration, GUI, commands, Vault, or Discord. +- Keep all service operations asynchronous through their existing `CompletableFuture` results. +- Preserve existing event cancellation and thread semantics. +- Publish only `com.eternalcode:parcellockers-api:0.5.0-BETA`, with sources and Javadoc JARs. +- Use `ETERNAL_CODE_MAVEN_USERNAME` and `ETERNAL_CODE_MAVEN_PASSWORD` only for the remote EternalCode repository. +- Apply TDD to every new behavior: write the test, observe the expected failure, implement the minimum, and rerun the focused and surrounding suites. + +--- + +## File Structure + +### Root build + +- Modify `settings.gradle.kts`: include `parcellockers-api` and `parcellockers-plugin`. +- Replace `build.gradle.kts`: keep shared plugin versions, coordinates, repositories, Java 21 compilation, and JUnit configuration only. +- Modify `buildSrc/src/main/kotlin/Versions.kt`: add `JSPECIFY = "1.0.0"`. + +### API module + +- Create `parcellockers-api/build.gradle.kts`: Java library dependencies and Maven publication. +- Create `parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersApi.java`: facade for the two public services. +- Create `parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersProvider.java`: guarded static lifecycle. +- Create `parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java`: public locker contract. +- Move parcel/locker models, parcel service contract, events, pagination, position, event base, and public exceptions from the plugin source tree into the matching API packages. +- Create `package-info.java` for each public API package to apply JSpecify `@NullMarked`. +- Create `parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java`: guards public service method names. +- Create `parcellockers-api/src/test/java/com/eternalcode/parcellockers/ParcelLockersProviderTest.java`: guards provider lifecycle. + +### Plugin module + +- Move the current root `src` tree to `parcellockers-plugin/src` and the current plugin build to `parcellockers-plugin/build.gradle.kts`. +- Create `parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java`: internal extension for rollback, invalidation, and administrative operations. +- Modify `ParcelServiceImpl`, `ParcelDispatchService`, `ParcelReturnService`, `GuiManager`, `DebugCommand`, and `ParcelLockers` to use the correct public or internal service type. +- Modify `LockerManager` to implement `LockerService` and use the API `ValidationException`. +- Create `parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java`: proves the public validation exception. +- Modify `ParcelReturnServiceTest` to mock `PluginParcelService` because it exercises cache invalidation. + +--- + +### Task 1: Convert the root build into two modules without changing runtime behavior + +**Files:** +- Modify: `settings.gradle.kts` +- Replace: `build.gradle.kts` +- Move: `src` to `parcellockers-plugin/src` +- Move: existing `build.gradle.kts` to `parcellockers-plugin/build.gradle.kts` before creating the new root build +- Create: `parcellockers-api/build.gradle.kts` +- Modify: `parcellockers-plugin/build.gradle.kts` +- Modify: `buildSrc/src/main/kotlin/Versions.kt` + +**Interfaces:** +- Consumes: the existing single-project Gradle build and unchanged source tree. +- Produces: Gradle projects `:parcellockers-api` and `:parcellockers-plugin`; the plugin project still builds and tests all existing code. + +- [ ] **Step 1: Record the pre-move baseline** + +Run: + +```powershell +.\gradlew.bat test +``` + +Expected: `BUILD SUCCESSFUL`. This was true before planning; rerun it immediately before source movement so the execution log contains fresh evidence. + +- [ ] **Step 2: Move the existing build and source tree mechanically** + +Run: + +```powershell +New-Item -ItemType Directory -Force -Path parcellockers-plugin | Out-Null +git mv build.gradle.kts parcellockers-plugin/build.gradle.kts +git mv src parcellockers-plugin/src +New-Item -ItemType Directory -Force -Path parcellockers-api | Out-Null +``` + +Expected: Git records renames; no Java package declarations change. + +- [ ] **Step 3: Replace `settings.gradle.kts` with the two-project definition** + +```kotlin +rootProject.name = "parcellockers" + +include("parcellockers-api") +include("parcellockers-plugin") + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +``` + +- [ ] **Step 4: Create the shared root `build.gradle.kts`** + +```kotlin +plugins { + id("de.eldoria.plugin-yml.paper") version "0.9.0" apply false + id("xyz.jpenilla.run-paper") version "3.0.2" apply false + id("com.gradleup.shadow") version "9.6.0" apply false + id("com.modrinth.minotaur") version "2.+" apply false +} + +allprojects { + group = "com.eternalcode" + version = "0.5.0-BETA" + + repositories { + maven("https://maven-central.storage-download.googleapis.com/maven2/") + maven("https://repo.triumphteam.dev/snapshots/") + maven("https://jitpack.io") + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://repo.eternalcode.pl/releases") + maven("https://storehouse.okaeri.eu/repository/maven-public/") + maven("https://nexus.scarsz.me/content/groups/public/") + } +} + +subprojects { + apply(plugin = "java") + + extensions.configure { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + } + + tasks.withType().configureEach { + options.encoding = "UTF-8" + options.isIncremental = true + options.compilerArgs.add("-parameters") + options.release = 21 + } + + tasks.withType().configureEach { + useJUnitPlatform() + } +} +``` + +- [ ] **Step 5: Create the initial API build** + +Create `parcellockers-api/build.gradle.kts`: + +```kotlin +plugins { + `java-library` +} + +dependencies { + compileOnlyApi("io.papermc.paper:paper-api:${Versions.PAPER_API}") + api("org.jspecify:jspecify:${Versions.JSPECIFY}") + + testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.JUNIT}") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} +``` + +Add to `buildSrc/src/main/kotlin/Versions.kt` next to the annotation versions: + +```kotlin +const val JSPECIFY = "1.0.0" +``` + +- [ ] **Step 6: Convert the moved plugin build to a subproject build** + +In `parcellockers-plugin/build.gradle.kts`: + +1. Remove `id("java")`; the root applies Java to every subproject. +2. Remove versions from the four external plugin declarations so the block is exactly: + +```kotlin +plugins { + id("de.eldoria.plugin-yml.paper") + id("xyz.jpenilla.run-paper") + id("com.gradleup.shadow") + id("com.modrinth.minotaur") +} +``` + +3. Remove the local `group`, `version`, `repositories`, `java`, `tasks.withType`, and `tasks.test` declarations now supplied by the root. +4. Add the API project as the first dependency: + +```kotlin +dependencies { + implementation(project(":parcellockers-api")) + + // existing dependencies remain unchanged below +} +``` + +5. Preserve the current root README lookup in the Modrinth block. +6. Add the root artifact destination to `shadowJar` while preserving the current archive name, exclusions, service merge, and relocation: + +```kotlin +shadowJar { + archiveFileName.set("ParcelLockers v${project.version}.jar") + destinationDirectory.set(rootProject.layout.buildDirectory.dir("libs")) + + exclude( + "org/intellij/lang/annotations/**", + "org/jetbrains/annotations/**", + "META-INF/**" + ) + + mergeServiceFiles() + + val relocationPrefix = "com.eternalcode.parcellockers.libs" + listOf("org.bstats").forEach { relocate(it, "$relocationPrefix.$it") } +} +``` + +- [ ] **Step 7: Verify the mechanical split** + +Run: + +```powershell +.\gradlew.bat projects +.\gradlew.bat :parcellockers-api:compileJava :parcellockers-plugin:test +``` + +Expected: both modules are listed; API has `NO-SOURCE`; every pre-existing plugin test passes. + +- [ ] **Step 8: Commit the module skeleton** + +```powershell +git add settings.gradle.kts build.gradle.kts buildSrc/src/main/kotlin/Versions.kt parcellockers-api parcellockers-plugin +git commit -m "build: split API and plugin modules" +``` + +--- + +### Task 2: Extract the public API surface and isolate plugin-only parcel operations + +**Files:** +- Move into API: `parcel/Parcel.java`, `parcel/ParcelSize.java`, `parcel/ParcelStatus.java`, `parcel/event/*.java`, `parcel/service/ParcelService.java` +- Move into API: `locker/Locker.java`, `locker/event/*.java` +- Move into API: `shared/Page.java`, `shared/PageResult.java`, `shared/Position.java`, `shared/event/CancellableEvent.java` +- Move into API: `shared/exception/ParcelLockersException.java`, `ParcelOperationException.java`, `ValidationException.java` +- Create: `parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java` +- Create: API `package-info.java` files +- Create: `parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java` +- Create: `parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java` +- Modify: `ParcelServiceImpl.java`, `ParcelDispatchService.java`, `ParcelReturnService.java`, `GuiManager.java`, `DebugCommand.java`, `ParcelLockers.java` +- Modify: `parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: module dependency `parcellockers-plugin -> parcellockers-api` from Task 1. +- Produces: `ParcelService` with only consumer-safe operations; `LockerService`; internal `PluginParcelService extends ParcelService` for rollback, invalidation, and administrative calls. + +- [ ] **Step 1: Write a failing API-surface test** + +Create `parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java`: + +```java +package com.eternalcode.parcellockers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class ApiSurfaceTest { + + @Test + void exposesOnlySupportedParcelOperations() { + Set methods = methodNames(ParcelService.class); + + assertEquals(Set.of( + "send", "update", "updateIfStatus", "collect", "get", + "getBySender", "getByReceiver", "getCollectible", "getReturnable", + "getAll", "delete" + ), methods); + assertFalse(methods.contains("rollbackSend")); + assertFalse(methods.contains("invalidate")); + assertFalse(methods.contains("deleteAll")); + } + + @Test + void exposesOnlySupportedLockerOperations() { + assertEquals(Set.of("get", "create", "delete", "rename", "isLockerFull"), + methodNames(LockerService.class)); + } + + private static Set methodNames(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .map(Method::getName) + .collect(Collectors.toSet()); + } +} +``` + +- [ ] **Step 2: Run the test and observe the missing API** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:test --tests "com.eternalcode.parcellockers.ApiSurfaceTest" +``` + +Expected: compilation fails because `ParcelService` and `LockerService` do not yet exist in the API module. + +- [ ] **Step 3: Move the required existing types into the API source tree** + +Create matching destination directories, then use `git mv` for the exact files listed in this task. Move the complete `parcel/event` and `locker/event` directories; move only the three public exception files so `DatabaseException.java` remains in the plugin module. + +After the move, remove `org.jetbrains.annotations.NotNull` imports and return annotations from the six moved event classes. Their `getHandlers()` methods become: + +```java +@Override +public HandlerList getHandlers() { + return HANDLER_LIST; +} +``` + +In `Position.java`, replace the Javadoc reference to plugin-only `PositionAdapter` with: + +```java +/** + * Immutable coordinates suitable for storage without retaining a Bukkit world instance. + */ +``` + +- [ ] **Step 4: Replace `ParcelService` with the consumer-safe contract** + +```java +package com.eternalcode.parcellockers.parcel.service; + +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; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.Nullable; + +public interface ParcelService { + + CompletableFuture send(Player sender, Parcel parcel, List items); + + CompletableFuture update(Parcel parcel); + + CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus); + + CompletableFuture collect(Player player, Parcel parcel); + + CompletableFuture> get(UUID uuid); + + CompletableFuture> getBySender(UUID sender, Page page); + + CompletableFuture> getByReceiver(UUID receiver, Page page); + + CompletableFuture> getCollectible( + UUID receiver, + @Nullable UUID destinationLocker, + Page page + ); + + CompletableFuture> getReturnable(UUID receiver, Page page); + + CompletableFuture> getAll(Page page); + + CompletableFuture delete(UUID uuid); + + CompletableFuture delete(Parcel parcel); +} +``` + +- [ ] **Step 5: Add the public locker contract** + +Create `parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java`: + +```java +package com.eternalcode.parcellockers.locker; + +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; +import com.eternalcode.parcellockers.shared.Position; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface LockerService { + + CompletableFuture> get(UUID uniqueId); + + CompletableFuture> get(Position position); + + CompletableFuture> get(Page page); + + CompletableFuture create(UUID uniqueId, String name, Position position, UUID playerUuid); + + CompletableFuture delete(UUID uniqueId, UUID playerUuid); + + CompletableFuture rename(UUID uniqueId, String newName); + + CompletableFuture isLockerFull(UUID uniqueId); +} +``` + +- [ ] **Step 6: Add JSpecify package defaults** + +Create this form of `package-info.java` in each listed API package: root, `parcel`, `parcel.event`, `parcel.service`, `locker`, `locker.event`, `shared`, `shared.event`, and `shared.exception`. + +Example for `parcel.service`: + +```java +@NullMarked +package com.eternalcode.parcellockers.parcel.service; + +import org.jspecify.annotations.NullMarked; +``` + +Use the matching package declaration in each other file. Do not annotate plugin-only packages. + +- [ ] **Step 7: Add the internal parcel extension and update implementation consumers** + +Create `parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java`: + +```java +package com.eternalcode.parcellockers.parcel.service; + +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public interface PluginParcelService extends ParcelService { + + CompletableFuture rollbackSend(Player sender, Parcel parcel); + + void invalidate(UUID uuid); + + CompletableFuture delete(CommandSender sender, Parcel parcel); + + CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService); +} +``` + +Make these exact type changes: + +- `ParcelServiceImpl implements PluginParcelService`. +- `ParcelDispatchService` field and constructor parameter: `PluginParcelService`. +- `ParcelReturnService` field and constructor parameter: `PluginParcelService`. +- `GuiManager` field and constructor parameter: `PluginParcelService`. +- `DebugCommand` field and constructor parameter: `PluginParcelService`. +- `ParcelLockers.onEnable()` local implementation variable: `PluginParcelService parcelService = new ParcelServiceImpl(...)`. +- `ParcelReturnServiceTest` fixture mock: `PluginParcelService`, with the corresponding import. + +Leave `AdminParcelService`, `ParcelSendTask`, `ReturnWindowPurgeTask`, and read-only GUI consumers typed as `ParcelService`; they use only the public contract. + +- [ ] **Step 8: Run focused and module tests** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:test --tests "com.eternalcode.parcellockers.ApiSurfaceTest" +.\gradlew.bat :parcellockers-plugin:test +``` + +Expected: `ApiSurfaceTest` passes with the exact method-name sets; all moved plugin tests pass. + +- [ ] **Step 9: Commit the API surface extraction** + +```powershell +git add parcellockers-api parcellockers-plugin +git commit -m "feat: extract parcel and locker API contracts" +``` + +--- + +### Task 3: Make locker validation part of the public API contract + +**Files:** +- Create: `parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java` +- Modify: `parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java` + +**Interfaces:** +- Consumes: `LockerService` and API `ValidationException` from Task 2. +- Produces: `LockerManager implements LockerService`; invalid create requests fail with `com.eternalcode.parcellockers.shared.exception.ValidationException`. + +- [ ] **Step 1: Write the failing validation test** + +```java +package com.eternalcode.parcellockers.locker; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.locker.repository.LockerRepository; +import com.eternalcode.parcellockers.locker.validation.LockerValidationService; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.shared.Position; +import com.eternalcode.parcellockers.shared.exception.ValidationException; +import com.eternalcode.parcellockers.shared.validation.ValidationResult; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; + +class LockerManagerTest { + + @Test + void exposesApiValidationExceptionForInvalidCreateRequest() { + LockerValidationService validation = mock(LockerValidationService.class); + UUID lockerId = UUID.randomUUID(); + UUID playerId = UUID.randomUUID(); + Position position = new Position(1, 2, 3, "world"); + when(validation.validateCreateParameters(lockerId, "locker", position)) + .thenReturn(ValidationResult.invalid("invalid locker")); + + LockerManager manager = new LockerManager( + null, + mock(LockerRepository.class), + validation, + mock(ParcelRepository.class), + mock(Server.class), + mock(Scheduler.class) + ); + + CompletionException exception = assertThrows(CompletionException.class, + () -> manager.create(lockerId, "locker", position, playerId).join()); + + assertInstanceOf(ValidationException.class, exception.getCause()); + } +} +``` + +- [ ] **Step 2: Run the test and verify the exception mismatch** + +Run: + +```powershell +.\gradlew.bat :parcellockers-plugin:test --tests "com.eternalcode.parcellockers.locker.LockerManagerTest" +``` + +Expected: the assertion fails because the cause is `eu.okaeri.configs.exception.ValidationException`. + +- [ ] **Step 3: Implement the public locker contract** + +In `LockerManager.java`: + +```java +import com.eternalcode.parcellockers.shared.exception.ValidationException; + +public class LockerManager implements LockerService { +``` + +Remove `import eu.okaeri.configs.exception.ValidationException;`. Keep the three existing `new ValidationException(...)` call sites unchanged so they now construct the API exception. Add `@Override` to every method declared by `LockerService`: all three `get` overloads, `create`, `delete`, `rename`, and `isLockerFull`. + +- [ ] **Step 4: Run the focused and API tests** + +Run: + +```powershell +.\gradlew.bat :parcellockers-plugin:test --tests "com.eternalcode.parcellockers.locker.LockerManagerTest" +.\gradlew.bat :parcellockers-api:test :parcellockers-plugin:test +``` + +Expected: the focused test sees the API `ValidationException`; both module suites pass. + +- [ ] **Step 5: Commit the locker implementation** + +```powershell +git add parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java +git commit -m "feat: expose locker service implementation" +``` + +--- + +### Task 4: Add the API provider and wire it to the Paper plugin lifecycle + +**Files:** +- Create: `parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersApi.java` +- Create: `parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersProvider.java` +- Create: `parcellockers-api/src/test/java/com/eternalcode/parcellockers/ParcelLockersProviderTest.java` +- Modify: `parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` + +**Interfaces:** +- Consumes: `ParcelService`, `PluginParcelService`, `LockerService`, and `LockerManager` from Tasks 2–3. +- Produces: `ParcelLockersProvider.provide()` and facade getters `getParcelService()` / `getLockerService()`. + +- [ ] **Step 1: Write failing provider lifecycle tests** + +```java +package com.eternalcode.parcellockers; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ParcelLockersProviderTest { + + @AfterEach + void resetProvider() { + try { + ParcelLockersProvider.deinitialize(); + } catch (IllegalStateException ignored) { + // The test already left the provider empty. + } + } + + @Test + void rejectsAccessBeforeInitialization() { + assertThrows(IllegalStateException.class, ParcelLockersProvider::provide); + } + + @Test + void returnsInitializedApi() { + ParcelLockersApi api = stubApi(); + + ParcelLockersProvider.initialize(api); + + assertSame(api, ParcelLockersProvider.provide()); + } + + @Test + void rejectsDuplicateInitialization() { + ParcelLockersProvider.initialize(stubApi()); + + assertThrows(IllegalStateException.class, + () -> ParcelLockersProvider.initialize(stubApi())); + } + + @Test + void rejectsAccessAfterDeinitialization() { + ParcelLockersProvider.initialize(stubApi()); + + ParcelLockersProvider.deinitialize(); + + assertThrows(IllegalStateException.class, ParcelLockersProvider::provide); + assertThrows(IllegalStateException.class, ParcelLockersProvider::deinitialize); + } + + private static ParcelLockersApi stubApi() { + return new ParcelLockersApi() { + @Override + public ParcelService getParcelService() { + throw new UnsupportedOperationException(); + } + + @Override + public LockerService getLockerService() { + throw new UnsupportedOperationException(); + } + }; + } +} +``` + +- [ ] **Step 2: Run the provider test and observe the missing classes** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:test --tests "com.eternalcode.parcellockers.ParcelLockersProviderTest" +``` + +Expected: test compilation fails because `ParcelLockersApi` and `ParcelLockersProvider` do not exist. + +- [ ] **Step 3: Add the facade** + +Create `ParcelLockersApi.java`: + +```java +package com.eternalcode.parcellockers; + +import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; + +public interface ParcelLockersApi { + + ParcelService getParcelService(); + + LockerService getLockerService(); +} +``` + +- [ ] **Step 4: Add the guarded provider** + +Create `ParcelLockersProvider.java`: + +```java +package com.eternalcode.parcellockers; + +import java.util.Objects; + +public final class ParcelLockersProvider { + + private static volatile ParcelLockersApi api; + + private ParcelLockersProvider() { + } + + public static ParcelLockersApi provide() { + ParcelLockersApi current = api; + if (current == null) { + throw new IllegalStateException("ParcelLockersApi has not been initialized"); + } + return current; + } + + static synchronized void initialize(ParcelLockersApi parcelLockersApi) { + Objects.requireNonNull(parcelLockersApi, "parcelLockersApi"); + if (api != null) { + throw new IllegalStateException("ParcelLockersApi has already been initialized"); + } + api = parcelLockersApi; + } + + static synchronized void deinitialize() { + if (api == null) { + throw new IllegalStateException("ParcelLockersApi has not been initialized"); + } + api = null; + } +} +``` + +- [ ] **Step 5: Run the provider tests green** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:test --tests "com.eternalcode.parcellockers.ParcelLockersProviderTest" +``` + +Expected: all four tests pass. + +- [ ] **Step 6: Wire the provider into `ParcelLockers`** + +Change the class declaration and add fields: + +```java +public final class ParcelLockers extends JavaPlugin implements ParcelLockersApi { + + private LiteCommands liteCommands; + private DatabaseManager databaseManager; + private Economy economy; + private DiscordClientManager discordClientManager; + private ParcelService parcelService; + private LockerService lockerService; + private boolean apiInitialized; +``` + +After constructing `PluginParcelService parcelService` and `LockerManager lockerManager`, assign: + +```java +this.parcelService = parcelService; +this.lockerService = lockerManager; +``` + +At the end of successful `onEnable()` initialization, after listeners and startup tasks have been registered: + +```java +ParcelLockersProvider.initialize(this); +this.apiInitialized = true; +``` + +At the beginning of `onDisable()`, before unregistering listeners or closing dependencies: + +```java +if (this.apiInitialized) { + ParcelLockersProvider.deinitialize(); + this.apiInitialized = false; +} +``` + +Add the facade getters before `setupEconomy()`: + +```java +@Override +public ParcelService getParcelService() { + return Objects.requireNonNull(this.parcelService, "ParcelService is not initialized"); +} + +@Override +public LockerService getLockerService() { + return Objects.requireNonNull(this.lockerService, "LockerService is not initialized"); +} +``` + +Add imports for `LockerService`, `PluginParcelService`, and `java.util.Objects`; keep the implementation construction typed as `PluginParcelService` so internal collaborators can receive rollback and invalidation operations. + +- [ ] **Step 7: Verify API and plugin lifecycle compilation** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:test +.\gradlew.bat :parcellockers-plugin:test +``` + +Expected: provider lifecycle and surface tests pass; the complete plugin suite passes. + +- [ ] **Step 8: Commit provider integration** + +```powershell +git add parcellockers-api/src parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +git commit -m "feat: expose ParcelLockers API provider" +``` + +--- + +### Task 5: Publish and verify the API artifact + +**Files:** +- Modify: `parcellockers-api/build.gradle.kts` +- Verify: `parcellockers-api/build/publications/maven/pom-default.xml` +- Verify: `parcellockers-api/build/libs/parcellockers-api-0.5.0-BETA.jar` +- Verify: `build/libs/ParcelLockers v0.5.0-BETA.jar` + +**Interfaces:** +- Consumes: the complete API module and provider from Tasks 2–4. +- Produces: Maven publication `com.eternalcode:parcellockers-api:0.5.0-BETA`, source/Javadoc artifacts, and a deployable plugin shadow JAR containing the API. + +- [ ] **Step 1: Add Maven publication configuration** + +Replace `parcellockers-api/build.gradle.kts` with: + +```kotlin +plugins { + `java-library` + `maven-publish` +} + +dependencies { + compileOnlyApi("io.papermc.paper:paper-api:${Versions.PAPER_API}") + api("org.jspecify:jspecify:${Versions.JSPECIFY}") + + testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.JUNIT}") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +java { + withSourcesJar() + withJavadocJar() +} + +publishing { + publications { + create("maven") { + artifactId = "parcellockers-api" + from(components["java"]) + } + } + + repositories { + mavenLocal() + maven { + name = "eternalcodeReleases" + url = uri("https://repo.eternalcode.pl/releases") + credentials { + username = System.getenv("ETERNAL_CODE_MAVEN_USERNAME") + password = System.getenv("ETERNAL_CODE_MAVEN_PASSWORD") + } + } + } +} +``` + +- [ ] **Step 2: Generate and inspect the publication metadata** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:generatePomFileForMavenPublication +Select-String -Path 'parcellockers-api\build\publications\maven\pom-default.xml' -Pattern 'paper-api','jspecify','1.0.0' +``` + +Expected: the POM contains `io.papermc.paper:paper-api` and `org.jspecify:jspecify:1.0.0`; it contains no plugin implementation dependency. + +- [ ] **Step 3: Publish locally and build the deployable plugin** + +Run: + +```powershell +.\gradlew.bat :parcellockers-api:publishToMavenLocal +.\gradlew.bat :parcellockers-plugin:shadowJar +``` + +Expected: publication and shadow tasks finish with `BUILD SUCCESSFUL`; source and Javadoc JARs are generated for the API. + +- [ ] **Step 4: Inspect API inclusion and implementation exclusion** + +Run: + +```powershell +jar tf 'parcellockers-api\build\libs\parcellockers-api-0.5.0-BETA.jar' | Select-String 'ParcelLockersApi','ParcelLockersProvider','ParcelService','LockerService','ParcelSendEvent','LockerCreateEvent' +jar tf 'parcellockers-api\build\libs\parcellockers-api-0.5.0-BETA.jar' | Select-String -Pattern '/repository/','/gui/','/database/','/discord/','/command/' +jar tf 'build\libs\ParcelLockers v0.5.0-BETA.jar' | Select-String 'com/eternalcode/parcellockers/ParcelLockersApi.class','com/eternalcode/parcellockers/ParcelLockers.class' +``` + +Expected: the first command finds all public entry points; the second prints nothing; the third finds both the API facade and plugin implementation in the deployable JAR. + +- [ ] **Step 5: Run final verification from a clean task graph** + +Run: + +```powershell +.\gradlew.bat clean test :parcellockers-api:publishToMavenLocal :parcellockers-plugin:shadowJar +git diff --check +git status --short +``` + +Expected: `BUILD SUCCESSFUL`, no whitespace errors, and only the intended Task 5 build-file change is uncommitted. + +- [ ] **Step 6: Commit publication support** + +```powershell +git add parcellockers-api/build.gradle.kts +git commit -m "build: publish ParcelLockers API artifact" +``` + +- [ ] **Step 7: Verify the committed branch state** + +Run: + +```powershell +.\gradlew.bat test :parcellockers-plugin:shadowJar +git status --short --branch +git log --oneline --decorate -7 +``` + +Expected: all tests and the deployable build pass; the worktree is clean on `codex/issue-176-api-module`; history contains the module, API contracts, locker service, provider, and publication commits. From 7ecefcb38dd8c72a8287fb21842947753722e58d Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:58:01 +0200 Subject: [PATCH 04/19] build: split API and plugin modules --- build.gradle.kts | 222 +++--------------- buildSrc/src/main/kotlin/Versions.kt | 1 + parcellockers-api/build.gradle.kts | 12 + parcellockers-plugin/build.gradle.kts | 176 ++++++++++++++ .../parcellockers/ParcelLockers.java | 0 .../parcellockers/ParcelLockersCommand.java | 0 .../ParcelLockersLibraryLoader.java | 0 .../command/debug/DebugCommand.java | 0 .../handler/InvalidUsageHandlerImpl.java | 0 .../MissingPermissionsHandlerImpl.java | 0 .../command/handler/NoticeHandler.java | 0 .../configuration/ConfigService.java | 0 .../implementation/MessageConfig.java | 0 .../implementation/PluginConfig.java | 0 .../serializable/ConfigItem.java | 0 .../parcellockers/content/ParcelContent.java | 0 .../content/ParcelContentManager.java | 0 .../repository/ParcelContentRepository.java | 0 .../ParcelContentRepositoryOrmLite.java | 0 .../repository/ParcelContentTable.java | 0 .../database/DatabaseManager.java | 0 .../parcellockers/database/DatabaseType.java | 0 .../database/persister/InstantPersister.java | 0 .../persister/ItemStackPersister.java | 0 .../database/persister/PositionPersister.java | 0 .../wrapper/AbstractRepositoryOrmLite.java | 0 .../parcellockers/delivery/Delivery.java | 0 .../delivery/DeliveryManager.java | 0 .../repository/DeliveryRepository.java | 0 .../repository/DeliveryRepositoryOrmLite.java | 0 .../delivery/repository/DeliveryTable.java | 0 .../discord/DiscordClientManager.java | 0 .../discord/DiscordFallbackLinkService.java | 0 .../parcellockers/discord/DiscordLink.java | 0 .../discord/DiscordLinkService.java | 0 .../discord/DiscordProviderPicker.java | 0 .../parcellockers/discord/LinkResult.java | 0 .../parcellockers/discord/UnlinkResult.java | 0 .../discord/argument/SnowflakeArgument.java | 0 .../discord/command/DiscordLinkCommand.java | 0 .../discord/command/DiscordUnlinkCommand.java | 0 .../DiscordDeliverNotificationController.java | 0 .../discordsrv/DiscordSrvLinkService.java | 0 .../command/DiscordSrvLinkCommand.java | 0 .../command/DiscordSrvUnlinkCommand.java | 0 .../DiscordSrvNotificationService.java | 0 .../Discord4JNotificationService.java | 0 .../DiscordNotificationService.java | 0 .../discord/repository/DiscordLinkEntity.java | 0 .../repository/DiscordLinkRepository.java | 0 .../DiscordLinkRepositoryOrmLite.java | 0 .../DiscordLinkValidationService.java | 0 .../DiscordVerificationDialogFactory.java | 0 .../DiscordVerificationService.java | 0 .../verification/VerificationCache.java | 0 .../VerificationCodeGenerator.java | 0 .../verification/VerificationData.java | 0 .../parcellockers/gui/GuiManager.java | 0 .../parcellockers/gui/GuiView.java | 0 .../gui/PaginatedGuiRefresher.java | 0 .../admin/AdminDestinationPickerGui.java | 0 .../gui/implementation/admin/AdminGui.java | 0 .../admin/AdminLockerEditGui.java | 0 .../admin/AdminLockerListGui.java | 0 .../admin/AdminParcelContentGui.java | 0 .../admin/AdminParcelEditGui.java | 0 .../admin/AdminParcelListGui.java | 0 .../admin/AdminReceiverPickerGui.java | 0 .../admin/AdminUserInspectGui.java | 0 .../admin/AdminUserListGui.java | 0 .../admin/ConfirmationDialogFactory.java | 0 .../implementation/locker/CollectionGui.java | 0 .../implementation/locker/DestinationGui.java | 0 .../implementation/locker/ItemStorageGui.java | 0 .../gui/implementation/locker/LockerGui.java | 0 .../implementation/locker/ReceiverGui.java | 0 .../locker/ReturnDepositGui.java | 0 .../gui/implementation/locker/ReturnGui.java | 0 .../gui/implementation/locker/SendingGui.java | 0 .../locker/SendingGuiState.java | 0 .../gui/implementation/remote/MainGui.java | 0 .../implementation/remote/ParcelListGui.java | 0 .../implementation/remote/SentParcelsGui.java | 0 .../itemstorage/ItemStorage.java | 0 .../itemstorage/ItemStorageManager.java | 0 .../event/ItemStorageUpdateEvent.java | 0 .../repository/ItemStorageRepository.java | 0 .../ItemStorageRepositoryOrmLite.java | 0 .../repository/ItemStorageTable.java | 0 .../parcellockers/locker/Locker.java | 0 .../parcellockers/locker/LockerManager.java | 0 .../controller/LockerBreakController.java | 0 .../LockerInteractionController.java | 0 .../controller/LockerPlaceController.java | 0 .../locker/event/LockerCreateEvent.java | 0 .../locker/event/LockerDeleteEvent.java | 0 .../locker/repository/LockerRepository.java | 0 .../repository/LockerRepositoryOrmLite.java | 0 .../locker/repository/LockerTable.java | 0 .../LockerConflictDetectionStrategy.java | 0 .../validation/LockerValidationService.java | 0 .../locker/validation/LockerValidator.java | 0 .../notification/NoticeService.java | 0 .../parcellockers/parcel/Parcel.java | 0 .../parcellockers/parcel/ParcelSize.java | 0 .../parcellockers/parcel/ParcelStatus.java | 0 .../parcel/command/ParcelCommand.java | 0 .../parcel/event/ParcelCollectEvent.java | 0 .../parcel/event/ParcelDeliverEvent.java | 0 .../parcel/event/ParcelReturnEvent.java | 0 .../parcel/event/ParcelSendEvent.java | 0 .../parcel/repository/ParcelRepository.java | 0 .../repository/ParcelRepositoryOrmLite.java | 0 .../parcel/repository/ParcelTable.java | 0 .../parcel/service/AdminParcelService.java | 0 .../parcel/service/EditResult.java | 0 .../parcel/service/ParcelDispatchService.java | 0 .../parcel/service/ParcelService.java | 0 .../parcel/service/ParcelServiceImpl.java | 0 .../parcel/task/ParcelSendTask.java | 0 .../parcel/util/PlaceholderUtil.java | 0 .../returns/CollectedParcel.java | 0 .../returns/ParcelReturnService.java | 0 .../returns/ParcelReturnValidationResult.java | 0 .../returns/ParcelReturnValidator.java | 0 .../returns/ReturnItemComparator.java | 0 .../returns/ReturnItemDifference.java | 0 .../returns/ReturnItemEquivalence.java | 0 .../returns/ReturnItemMismatch.java | 0 .../returns/ReturnMismatchFormatter.java | 0 .../returns/ReturnMismatchType.java | 0 .../repository/CollectedParcelRepository.java | 0 .../CollectedParcelRepositoryOrmLite.java | 0 .../repository/CollectedParcelTable.java | 0 .../repository/ParcelReturnRepository.java | 0 .../ParcelReturnRepositoryOrmLite.java | 0 .../returns/task/ReturnWindowPurgeTask.java | 0 .../parcellockers/shared/Page.java | 0 .../parcellockers/shared/PageResult.java | 0 .../parcellockers/shared/Position.java | 0 .../parcellockers/shared/PositionAdapter.java | 0 .../shared/event/CancellableEvent.java | 0 .../shared/exception/DatabaseException.java | 0 .../exception/ParcelLockersException.java | 0 .../exception/ParcelOperationException.java | 0 .../shared/exception/ValidationException.java | 0 .../shared/validation/ValidationResult.java | 0 .../shared/validation/ValidationRule.java | 0 .../UpdaterNotificationController.java | 0 .../parcellockers/updater/UpdaterService.java | 0 .../eternalcode/parcellockers/user/User.java | 0 .../parcellockers/user/UserManager.java | 0 .../parcellockers/user/UserManagerImpl.java | 0 .../user/controller/LoadUserController.java | 0 .../controller/PrepareUserController.java | 0 .../user/event/UserChangeNameEvent.java | 0 .../user/event/UserCreateEvent.java | 0 .../user/repository/UserRepository.java | 0 .../repository/UserRepositoryOrmLite.java | 0 .../user/repository/UserTable.java | 0 .../UserConflictDetectionStrategy.java | 0 .../validation/UserValidationService.java | 0 .../user/validation/UserValidator.java | 0 .../parcellockers/util/DurationUtil.java | 0 .../parcellockers/util/InventoryUtil.java | 0 .../parcellockers/util/MaterialUtil.java | 0 .../parcellockers/TestScheduler.java | 0 .../content/ParcelContentTest.java | 0 ...lectedParcelRepositoryIntegrationTest.java | 0 .../database/IntegrationTestSpec.java | 0 .../database/LockerRenameIntegrationTest.java | 0 .../LockerRepositoryIntegrationTest.java | 0 .../ParcelContentUpdateIntegrationTest.java | 0 .../ParcelFindCollectibleIntegrationTest.java | 0 .../ParcelFindPageIntegrationTest.java | 0 .../ParcelRepositoryIntegrationTest.java | 0 ...ReturnCommitRepositoryIntegrationTest.java | 0 ...ParcelReturnRepositoryIntegrationTest.java | 0 .../admin/AdminParcelEditGuiTest.java | 0 .../locker/ReturnDepositGuiTest.java | 0 .../implementation/locker/ReturnGuiTest.java | 0 .../parcel/repository/ParcelPageTest.java | 0 .../service/AdminParcelServiceTest.java | 0 .../parcel/task/ParcelSendTaskTest.java | 0 .../returns/ParcelReturnServiceTest.java | 0 .../returns/ParcelReturnValidatorTest.java | 0 .../returns/ReturnItemEquivalenceTest.java | 0 .../returns/ReturnMismatchFormatterTest.java | 0 settings.gradle.kts | 4 + 189 files changed, 221 insertions(+), 194 deletions(-) create mode 100644 parcellockers-api/build.gradle.kts create mode 100644 parcellockers-plugin/build.gradle.kts rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/ParcelLockers.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/command/handler/InvalidUsageHandlerImpl.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/command/handler/MissingPermissionsHandlerImpl.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/command/handler/NoticeHandler.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/configuration/ConfigService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/configuration/serializable/ConfigItem.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/content/ParcelContent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/database/DatabaseType.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/database/persister/InstantPersister.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/database/persister/ItemStackPersister.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/delivery/Delivery.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/DiscordFallbackLinkService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/DiscordLinkService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/DiscordProviderPicker.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/LinkResult.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/UnlinkResult.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/argument/SnowflakeArgument.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/controller/DiscordDeliverNotificationController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/discordsrv/DiscordSrvLinkService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvLinkCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvUnlinkCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/discordsrv/notification/DiscordSrvNotificationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/notification/DiscordNotificationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/verification/DiscordLinkValidationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationDialogFactory.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCache.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCodeGenerator.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/discord/verification/VerificationData.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/GuiManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/GuiView.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/PaginatedGuiRefresher.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/DestinationGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReceiverGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/Locker.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/LockerManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/repository/LockerTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/validation/LockerConflictDetectionStrategy.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidator.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/notification/NoticeService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/Parcel.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/parcel/util/PlaceholderUtil.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/Page.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/PageResult.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/Position.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/PositionAdapter.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/exception/DatabaseException.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/validation/ValidationResult.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/shared/validation/ValidationRule.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/updater/UpdaterNotificationController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/updater/UpdaterService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/User.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/UserManager.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/controller/LoadUserController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/controller/PrepareUserController.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/event/UserCreateEvent.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/repository/UserRepository.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/repository/UserTable.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/validation/UserConflictDetectionStrategy.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/validation/UserValidationService.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/user/validation/UserValidator.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/util/DurationUtil.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java (100%) rename {src => parcellockers-plugin/src}/main/java/com/eternalcode/parcellockers/util/MaterialUtil.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/TestScheduler.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/content/ParcelContentTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/IntegrationTestSpec.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGuiTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/parcel/repository/ParcelPageTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java (100%) rename {src => parcellockers-plugin/src}/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java (100%) diff --git a/build.gradle.kts b/build.gradle.kts index f28be0dc..9aa61bd9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,206 +1,40 @@ -import net.minecrell.pluginyml.paper.PaperPluginDescription -import xyz.jpenilla.runtask.task.AbstractRun - plugins { - id("java") - id("de.eldoria.plugin-yml.paper") version "0.9.0" - id("xyz.jpenilla.run-paper") version "3.0.2" - id("com.gradleup.shadow") version "9.6.0" - id("com.modrinth.minotaur") version "2.+" -} - -group = "com.eternalcode" -version = "0.5.0-BETA" - -repositories { - maven("https://maven-central.storage-download.googleapis.com/maven2/") // maven central mirror - - maven("https://repo.triumphteam.dev/snapshots/") - maven("https://jitpack.io") - maven("https://repo.papermc.io/repository/maven-public/") - maven("https://repo.eternalcode.pl/releases") - maven("https://storehouse.okaeri.eu/repository/maven-public/") - maven("https://nexus.scarsz.me/content/groups/public/") // DiscordSRV -} - -dependencies { - // minecraft development api - compileOnly("io.papermc.paper:paper-api:${Versions.PAPER_API}") - paperLibrary("dev.rollczi:litecommands-bukkit:${Versions.LITECOMMANDS}") - paperLibrary("dev.rollczi:litecommands-adventure:${Versions.LITECOMMANDS}") - - // gui - paperLibrary("dev.triumphteam:triumph-gui-paper:${Versions.TRIUMPH_GUI}") - - // configs - paperLibrary("eu.okaeri:okaeri-configs-serdes-commons:${Versions.OKAERI_CONFIGS}") - paperLibrary("eu.okaeri:okaeri-configs-serdes-bukkit:${Versions.OKAERI_CONFIGS}") - paperLibrary("eu.okaeri:okaeri-configs-yaml-bukkit:${Versions.OKAERI_CONFIGS}") - - // gitcheck - paperLibrary("com.eternalcode:gitcheck:${Versions.GITCHECK}") - - // metrics - implementation("org.bstats:bstats-bukkit:${Versions.BSTATS}") - - // database - paperLibrary("com.zaxxer:HikariCP:${Versions.HIKARICP}") - paperLibrary("com.j256.ormlite:ormlite-jdbc:${Versions.ORMLITE}") - paperLibrary("com.h2database:h2:${Versions.H2}") - paperLibrary("org.postgresql:postgresql:${Versions.POSTGRESQL}") - - // lombok - compileOnly("org.projectlombok:lombok:${Versions.LOMBOK}") - annotationProcessor("org.projectlombok:lombok:${Versions.LOMBOK}") - - // jetbrains annotations - compileOnly("org.jetbrains:annotations:${Versions.JETBRAINS_ANNOTATIONS}") - - // jackson-bukkit - paperLibrary("de.eldoria.jacksonbukkit:paper:${Versions.JACKSON_BUKKIT}") - - // completable-futures - paperLibrary("com.spotify:completable-futures:${Versions.COMPLETABLE_FUTURES}") - - // eternalcode commons - paperLibrary("com.eternalcode:eternalcode-commons-adventure:${Versions.ETERNALCODE_COMMONS}") - paperLibrary("com.eternalcode:eternalcode-commons-bukkit:${Versions.ETERNALCODE_COMMONS}") - paperLibrary("com.eternalcode:eternalcode-commons-shared:${Versions.ETERNALCODE_COMMONS}") - - // multification - paperLibrary("com.eternalcode:multification-bukkit:${Versions.MULTIFICATION}") - paperLibrary("com.eternalcode:multification-okaeri:${Versions.MULTIFICATION}") - - // caffeine - paperLibrary("com.github.ben-manes.caffeine:caffeine:${Versions.CAFFEINE}") - - // vault - compileOnly("com.github.MilkBowl:VaultAPI:${Versions.VAULT_API}") - - // discord integration library - paperLibrary("com.discord4j:discord4j-core:${Versions.DISCORD4J}") - - // discordsrv (optional integration) - compileOnly("com.discordsrv:discordsrv:${Versions.DISCORDSRV}") - - testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.JUNIT}") - testImplementation("org.junit.jupiter:junit-jupiter-params:${Versions.JUNIT}") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - - testImplementation("org.mockito:mockito-core:${Versions.MOCKITO}") - - 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-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}") - 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}") -} - -java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) -} - -paper { - name = "ParcelLockers" - main = "com.eternalcode.parcellockers.ParcelLockers" - version = project.version.toString() - apiVersion = "1.21" - author = "EternalCodeTeam" - website = "https://github.com/EternalCodeTeam/ParcelLockers" - loader = "com.eternalcode.parcellockers.ParcelLockersLibraryLoader" - generateLibrariesJson = true - foliaSupported = false - serverDependencies { - register("Vault") { - required = true - load = PaperPluginDescription.RelativeLoadOrder.BEFORE - } - register("DiscordSRV") { - required = false - load = PaperPluginDescription.RelativeLoadOrder.BEFORE - } + id("de.eldoria.plugin-yml.paper") version "0.9.0" apply false + id("xyz.jpenilla.run-paper") version "3.0.2" apply false + id("com.gradleup.shadow") version "9.6.0" apply false + id("com.modrinth.minotaur") version "2.+" apply false +} + +allprojects { + group = "com.eternalcode" + version = "0.5.0-BETA" + + repositories { + maven("https://maven-central.storage-download.googleapis.com/maven2/") + maven("https://repo.triumphteam.dev/snapshots/") + maven("https://jitpack.io") + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://repo.eternalcode.pl/releases") + maven("https://storehouse.okaeri.eu/repository/maven-public/") + maven("https://nexus.scarsz.me/content/groups/public/") } } -tasks.withType { - options.encoding = "UTF-8" - options.isIncremental = true - options.compilerArgs.add("-parameters") - options.release = 21 -} +subprojects { + apply(plugin = "java") -tasks.withType { - javaLauncher = javaToolchains.launcherFor { - vendor = JvmVendorSpec.JETBRAINS - languageVersion = JavaLanguageVersion.of(25) + extensions.configure { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } -} -modrinth { - token.set(providers.environmentVariable("MODRINTH_TOKEN")) - projectId.set("parcellockers") - versionNumber.set(project.version.toString()) - versionType.set(getVersionType(project.version.toString())) - changelog.set(providers.environmentVariable("MODRINTH_CHANGELOG")) - debugMode.set(providers.environmentVariable("MODRINTH_DEBUG").map(String::toBoolean).orElse(false)) - uploadFile.set(tasks.shadowJar) - 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() -} - -tasks { - runServer { - minecraftVersion("26.1.2") - downloadPlugins { - modrinth("luckperms", "v5.5.17-bukkit") - modrinth("vaultunlocked", "2.17.0") - modrinth("essentialsx", "2.22.0") -// modrinth("discordsrv", "1.30.4") // uncomment to test with DiscordSRV integration - } - jvmArgs("-Dcom.mojang.eula.agree=true", "-XX:+AllowEnhancedClassRedefinition") + tasks.withType().configureEach { + options.encoding = "UTF-8" + options.isIncremental = true + options.compilerArgs.add("-parameters") + options.release = 21 } - test { + tasks.withType().configureEach { useJUnitPlatform() } - - shadowJar { - archiveFileName.set("ParcelLockers v${project.version}.jar") - - exclude( - "org/intellij/lang/annotations/**", - "org/jetbrains/annotations/**", - "META-INF/**" - ) - - mergeServiceFiles() - - val relocationPrefix = "com.eternalcode.parcellockers.libs" - - listOf( - "org.bstats" - ).forEach { relocate(it, "$relocationPrefix.$it") } - } -} - -fun getVersionType(version: String): String { - return when { - version.contains("SNAPSHOT") -> "beta" - version.contains("alpha", true) -> "alpha" - version.contains("beta", true) -> "beta" - else -> "release" - } } diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index a02aabee..396614cf 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -16,6 +16,7 @@ object Versions { const val LOMBOK = "1.18.46" const val JETBRAINS_ANNOTATIONS = "26.1.0" + const val JSPECIFY = "1.0.0" const val JACKSON_BUKKIT = "2.0.0" const val COMPLETABLE_FUTURES = "0.3.6" diff --git a/parcellockers-api/build.gradle.kts b/parcellockers-api/build.gradle.kts new file mode 100644 index 00000000..cbb83c2a --- /dev/null +++ b/parcellockers-api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `java-library` +} + +dependencies { + compileOnlyApi("io.papermc.paper:paper-api:${Versions.PAPER_API}") + api("org.jspecify:jspecify:${Versions.JSPECIFY}") + + testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.JUNIT}") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} diff --git a/parcellockers-plugin/build.gradle.kts b/parcellockers-plugin/build.gradle.kts new file mode 100644 index 00000000..930bca5a --- /dev/null +++ b/parcellockers-plugin/build.gradle.kts @@ -0,0 +1,176 @@ +import net.minecrell.pluginyml.paper.PaperPluginDescription +import xyz.jpenilla.runtask.task.AbstractRun + +plugins { + id("de.eldoria.plugin-yml.paper") + id("xyz.jpenilla.run-paper") + id("com.gradleup.shadow") + id("com.modrinth.minotaur") +} + +dependencies { + implementation(project(":parcellockers-api")) + + // minecraft development api + compileOnly("io.papermc.paper:paper-api:${Versions.PAPER_API}") + paperLibrary("dev.rollczi:litecommands-bukkit:${Versions.LITECOMMANDS}") + paperLibrary("dev.rollczi:litecommands-adventure:${Versions.LITECOMMANDS}") + + // gui + paperLibrary("dev.triumphteam:triumph-gui-paper:${Versions.TRIUMPH_GUI}") + + // configs + paperLibrary("eu.okaeri:okaeri-configs-serdes-commons:${Versions.OKAERI_CONFIGS}") + paperLibrary("eu.okaeri:okaeri-configs-serdes-bukkit:${Versions.OKAERI_CONFIGS}") + paperLibrary("eu.okaeri:okaeri-configs-yaml-bukkit:${Versions.OKAERI_CONFIGS}") + + // gitcheck + paperLibrary("com.eternalcode:gitcheck:${Versions.GITCHECK}") + + // metrics + implementation("org.bstats:bstats-bukkit:${Versions.BSTATS}") + + // database + paperLibrary("com.zaxxer:HikariCP:${Versions.HIKARICP}") + paperLibrary("com.j256.ormlite:ormlite-jdbc:${Versions.ORMLITE}") + paperLibrary("com.h2database:h2:${Versions.H2}") + paperLibrary("org.postgresql:postgresql:${Versions.POSTGRESQL}") + + // lombok + compileOnly("org.projectlombok:lombok:${Versions.LOMBOK}") + annotationProcessor("org.projectlombok:lombok:${Versions.LOMBOK}") + + // jetbrains annotations + compileOnly("org.jetbrains:annotations:${Versions.JETBRAINS_ANNOTATIONS}") + + // jackson-bukkit + paperLibrary("de.eldoria.jacksonbukkit:paper:${Versions.JACKSON_BUKKIT}") + + // completable-futures + paperLibrary("com.spotify:completable-futures:${Versions.COMPLETABLE_FUTURES}") + + // eternalcode commons + paperLibrary("com.eternalcode:eternalcode-commons-adventure:${Versions.ETERNALCODE_COMMONS}") + paperLibrary("com.eternalcode:eternalcode-commons-bukkit:${Versions.ETERNALCODE_COMMONS}") + paperLibrary("com.eternalcode:eternalcode-commons-shared:${Versions.ETERNALCODE_COMMONS}") + + // multification + paperLibrary("com.eternalcode:multification-bukkit:${Versions.MULTIFICATION}") + paperLibrary("com.eternalcode:multification-okaeri:${Versions.MULTIFICATION}") + + // caffeine + paperLibrary("com.github.ben-manes.caffeine:caffeine:${Versions.CAFFEINE}") + + // vault + compileOnly("com.github.MilkBowl:VaultAPI:${Versions.VAULT_API}") + + // discord integration library + paperLibrary("com.discord4j:discord4j-core:${Versions.DISCORD4J}") + + // discordsrv (optional integration) + compileOnly("com.discordsrv:discordsrv:${Versions.DISCORDSRV}") + + testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.JUNIT}") + testImplementation("org.junit.jupiter:junit-jupiter-params:${Versions.JUNIT}") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + testImplementation("org.mockito:mockito-core:${Versions.MOCKITO}") + + 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-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}") + 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}") +} + +paper { + name = "ParcelLockers" + main = "com.eternalcode.parcellockers.ParcelLockers" + version = project.version.toString() + apiVersion = "1.21" + author = "EternalCodeTeam" + website = "https://github.com/EternalCodeTeam/ParcelLockers" + loader = "com.eternalcode.parcellockers.ParcelLockersLibraryLoader" + generateLibrariesJson = true + foliaSupported = false + serverDependencies { + register("Vault") { + required = true + load = PaperPluginDescription.RelativeLoadOrder.BEFORE + } + register("DiscordSRV") { + required = false + load = PaperPluginDescription.RelativeLoadOrder.BEFORE + } + } +} + +tasks.withType { + javaLauncher = javaToolchains.launcherFor { + vendor = JvmVendorSpec.JETBRAINS + languageVersion = JavaLanguageVersion.of(25) + } +} + +modrinth { + token.set(providers.environmentVariable("MODRINTH_TOKEN")) + projectId.set("parcellockers") + versionNumber.set(project.version.toString()) + versionType.set(getVersionType(project.version.toString())) + changelog.set(providers.environmentVariable("MODRINTH_CHANGELOG")) + debugMode.set(providers.environmentVariable("MODRINTH_DEBUG").map(String::toBoolean).orElse(false)) + uploadFile.set(tasks.shadowJar) + 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() +} + +tasks { + runServer { + minecraftVersion("26.1.2") + downloadPlugins { + modrinth("luckperms", "v5.5.17-bukkit") + modrinth("vaultunlocked", "2.17.0") + modrinth("essentialsx", "2.22.0") +// modrinth("discordsrv", "1.30.4") // uncomment to test with DiscordSRV integration + } + jvmArgs("-Dcom.mojang.eula.agree=true", "-XX:+AllowEnhancedClassRedefinition") + } + + shadowJar { + archiveFileName.set("ParcelLockers v${project.version}.jar") + destinationDirectory.set(rootProject.layout.buildDirectory.dir("libs")) + + exclude( + "org/intellij/lang/annotations/**", + "org/jetbrains/annotations/**", + "META-INF/**" + ) + + mergeServiceFiles() + + val relocationPrefix = "com.eternalcode.parcellockers.libs" + listOf("org.bstats").forEach { relocate(it, "$relocationPrefix.$it") } + } +} + +fun getVersionType(version: String): String { + return when { + version.contains("SNAPSHOT") -> "beta" + version.contains("alpha", true) -> "alpha" + version.contains("beta", true) -> "beta" + else -> "release" + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/ParcelLockers.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockersCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java diff --git a/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/command/handler/InvalidUsageHandlerImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/handler/InvalidUsageHandlerImpl.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/command/handler/InvalidUsageHandlerImpl.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/handler/InvalidUsageHandlerImpl.java diff --git a/src/main/java/com/eternalcode/parcellockers/command/handler/MissingPermissionsHandlerImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/handler/MissingPermissionsHandlerImpl.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/command/handler/MissingPermissionsHandlerImpl.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/handler/MissingPermissionsHandlerImpl.java diff --git a/src/main/java/com/eternalcode/parcellockers/command/handler/NoticeHandler.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/handler/NoticeHandler.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/command/handler/NoticeHandler.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/handler/NoticeHandler.java diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/ConfigService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/ConfigService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/configuration/ConfigService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/ConfigService.java diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/serializable/ConfigItem.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/serializable/ConfigItem.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/configuration/serializable/ConfigItem.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/serializable/ConfigItem.java diff --git a/src/main/java/com/eternalcode/parcellockers/content/ParcelContent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/ParcelContent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/content/ParcelContent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/ParcelContent.java diff --git a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/DatabaseManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/database/DatabaseType.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/DatabaseType.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/database/DatabaseType.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/DatabaseType.java diff --git a/src/main/java/com/eternalcode/parcellockers/database/persister/InstantPersister.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/InstantPersister.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/database/persister/InstantPersister.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/InstantPersister.java diff --git a/src/main/java/com/eternalcode/parcellockers/database/persister/ItemStackPersister.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/ItemStackPersister.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/database/persister/ItemStackPersister.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/ItemStackPersister.java diff --git a/src/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java diff --git a/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/wrapper/AbstractRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/Delivery.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/Delivery.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/delivery/Delivery.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/Delivery.java diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordFallbackLinkService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordFallbackLinkService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/DiscordFallbackLinkService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordFallbackLinkService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordLinkService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordLinkService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/DiscordLinkService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordLinkService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordProviderPicker.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordProviderPicker.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/DiscordProviderPicker.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/DiscordProviderPicker.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/LinkResult.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/LinkResult.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/LinkResult.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/LinkResult.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/UnlinkResult.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/UnlinkResult.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/UnlinkResult.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/UnlinkResult.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/argument/SnowflakeArgument.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/argument/SnowflakeArgument.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/argument/SnowflakeArgument.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/argument/SnowflakeArgument.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/controller/DiscordDeliverNotificationController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/controller/DiscordDeliverNotificationController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/controller/DiscordDeliverNotificationController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/controller/DiscordDeliverNotificationController.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/DiscordSrvLinkService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/DiscordSrvLinkService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/discordsrv/DiscordSrvLinkService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/DiscordSrvLinkService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvLinkCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvLinkCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvLinkCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvLinkCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvUnlinkCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvUnlinkCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvUnlinkCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/command/DiscordSrvUnlinkCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/notification/DiscordSrvNotificationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/notification/DiscordSrvNotificationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/discordsrv/notification/DiscordSrvNotificationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/discordsrv/notification/DiscordSrvNotificationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/notification/DiscordNotificationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/notification/DiscordNotificationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/notification/DiscordNotificationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/notification/DiscordNotificationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordLinkValidationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordLinkValidationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordLinkValidationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordLinkValidationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationDialogFactory.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationDialogFactory.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationDialogFactory.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationDialogFactory.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCache.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCache.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCache.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCache.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCodeGenerator.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCodeGenerator.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCodeGenerator.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationCodeGenerator.java diff --git a/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationData.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationData.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationData.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/VerificationData.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiView.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiView.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/GuiView.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiView.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/PaginatedGuiRefresher.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/PaginatedGuiRefresher.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/PaginatedGuiRefresher.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/PaginatedGuiRefresher.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminDestinationPickerGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerEditGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminLockerListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelContentGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminReceiverPickerGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserInspectGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminUserListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/ConfirmationDialogFactory.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/CollectionGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/DestinationGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/DestinationGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/DestinationGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/DestinationGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ItemStorageGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReceiverGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReceiverGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReceiverGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReceiverGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/event/ItemStorageUpdateEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/repository/ItemStorageTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/Locker.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/Locker.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/Locker.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/Locker.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerBreakController.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerInteractionController.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/controller/LockerPlaceController.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/repository/LockerTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/repository/LockerTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerConflictDetectionStrategy.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerConflictDetectionStrategy.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/validation/LockerConflictDetectionStrategy.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerConflictDetectionStrategy.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidator.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidator.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidator.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/validation/LockerValidator.java diff --git a/src/main/java/com/eternalcode/parcellockers/notification/NoticeService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/notification/NoticeService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/notification/NoticeService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/notification/NoticeService.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/util/PlaceholderUtil.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/util/PlaceholderUtil.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/util/PlaceholderUtil.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/util/PlaceholderUtil.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/Page.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Page.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/Page.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Page.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/PageResult.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/Position.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Position.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/Position.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Position.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/PositionAdapter.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/PositionAdapter.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/PositionAdapter.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/PositionAdapter.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/exception/DatabaseException.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/DatabaseException.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/exception/DatabaseException.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/DatabaseException.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationResult.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationResult.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationResult.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationResult.java diff --git a/src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationRule.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationRule.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationRule.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/validation/ValidationRule.java diff --git a/src/main/java/com/eternalcode/parcellockers/updater/UpdaterNotificationController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/updater/UpdaterNotificationController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/updater/UpdaterNotificationController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/updater/UpdaterNotificationController.java diff --git a/src/main/java/com/eternalcode/parcellockers/updater/UpdaterService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/updater/UpdaterService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/updater/UpdaterService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/updater/UpdaterService.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/User.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/User.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/User.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/User.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/UserManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/UserManager.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/UserManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/UserManager.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/UserManagerImpl.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/controller/LoadUserController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/controller/LoadUserController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/controller/LoadUserController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/controller/LoadUserController.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/controller/PrepareUserController.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/controller/PrepareUserController.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/controller/PrepareUserController.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/controller/PrepareUserController.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/event/UserChangeNameEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/event/UserCreateEvent.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/event/UserCreateEvent.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/event/UserCreateEvent.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/event/UserCreateEvent.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepository.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/repository/UserRepository.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepository.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/repository/UserTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/repository/UserTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/validation/UserConflictDetectionStrategy.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/validation/UserConflictDetectionStrategy.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/validation/UserConflictDetectionStrategy.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/validation/UserConflictDetectionStrategy.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/validation/UserValidationService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/validation/UserValidationService.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/validation/UserValidationService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/validation/UserValidationService.java diff --git a/src/main/java/com/eternalcode/parcellockers/user/validation/UserValidator.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/validation/UserValidator.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/user/validation/UserValidator.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/validation/UserValidator.java diff --git a/src/main/java/com/eternalcode/parcellockers/util/DurationUtil.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/util/DurationUtil.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/util/DurationUtil.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/util/DurationUtil.java diff --git a/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/util/InventoryUtil.java diff --git a/src/main/java/com/eternalcode/parcellockers/util/MaterialUtil.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/util/MaterialUtil.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/util/MaterialUtil.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/util/MaterialUtil.java diff --git a/src/test/java/com/eternalcode/parcellockers/TestScheduler.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/TestScheduler.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/TestScheduler.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/TestScheduler.java diff --git a/src/test/java/com/eternalcode/parcellockers/content/ParcelContentTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/content/ParcelContentTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/content/ParcelContentTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/content/ParcelContentTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/IntegrationTestSpec.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/IntegrationTestSpec.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/IntegrationTestSpec.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/IntegrationTestSpec.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGuiTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGuiTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGuiTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGuiTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/repository/ParcelPageTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/repository/ParcelPageTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/parcel/repository/ParcelPageTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/repository/ParcelPageTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java similarity index 100% rename from src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java diff --git a/settings.gradle.kts b/settings.gradle.kts index 79294bb6..5f804553 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,8 @@ rootProject.name = "parcellockers" + +include("parcellockers-api") +include("parcellockers-plugin") + plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } From 6bea75baf8f1505989ebc1849877fc692440c8c9 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:17:48 +0200 Subject: [PATCH 05/19] feat: extract parcel and locker API contracts --- parcellockers-api/build.gradle.kts | 1 + .../parcellockers/locker/Locker.java | 0 .../parcellockers/locker/LockerService.java | 25 +++++++++++ .../locker/event/LockerCreateEvent.java | 3 +- .../locker/event/LockerDeleteEvent.java | 3 +- .../locker/event/package-info.java | 4 ++ .../parcellockers/locker/package-info.java | 4 ++ .../parcellockers/package-info.java | 4 ++ .../parcellockers/parcel/Parcel.java | 0 .../parcellockers/parcel/ParcelSize.java | 0 .../parcellockers/parcel/ParcelStatus.java | 0 .../parcel/event/ParcelCollectEvent.java | 3 +- .../parcel/event/ParcelDeliverEvent.java | 3 +- .../parcel/event/ParcelReturnEvent.java | 3 +- .../parcel/event/ParcelSendEvent.java | 3 +- .../parcel/event/package-info.java | 4 ++ .../parcellockers/parcel/package-info.java | 4 ++ .../parcel/service/ParcelService.java | 33 +++------------ .../parcel/service/package-info.java | 4 ++ .../parcellockers/shared/Page.java | 0 .../parcellockers/shared/PageResult.java | 0 .../parcellockers/shared/Position.java | 4 +- .../shared/event/CancellableEvent.java | 0 .../shared/event/package-info.java | 4 ++ .../exception/ParcelLockersException.java | 0 .../exception/ParcelOperationException.java | 0 .../shared/exception/ValidationException.java | 0 .../shared/exception/package-info.java | 4 ++ .../parcellockers/shared/package-info.java | 4 ++ .../parcellockers/ApiSurfaceTest.java | 41 +++++++++++++++++++ .../parcellockers/ParcelLockers.java | 4 +- .../command/debug/DebugCommand.java | 6 +-- .../parcellockers/gui/GuiManager.java | 6 +-- .../parcel/service/ParcelDispatchService.java | 4 +- .../parcel/service/ParcelServiceImpl.java | 2 +- .../parcel/service/PluginParcelService.java | 19 +++++++++ .../returns/ParcelReturnService.java | 6 +-- .../returns/ParcelReturnServiceTest.java | 4 +- 38 files changed, 151 insertions(+), 58 deletions(-) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/locker/Locker.java (100%) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java (90%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java (92%) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/package-info.java create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/package-info.java create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/package-info.java rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java (88%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java (88%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java (88%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java (88%) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/package-info.java create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/package-info.java rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java (50%) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/package-info.java rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/Page.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/Position.java (90%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java (100%) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/event/package-info.java rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java (100%) rename {parcellockers-plugin => parcellockers-api}/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java (100%) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/package-info.java create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/package-info.java create mode 100644 parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java create mode 100644 parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java diff --git a/parcellockers-api/build.gradle.kts b/parcellockers-api/build.gradle.kts index cbb83c2a..dd4dbcd8 100644 --- a/parcellockers-api/build.gradle.kts +++ b/parcellockers-api/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { api("org.jspecify:jspecify:${Versions.JSPECIFY}") testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.JUNIT}") + testRuntimeOnly("io.papermc.paper:paper-api:${Versions.PAPER_API}") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/Locker.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/Locker.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/Locker.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/Locker.java diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java new file mode 100644 index 00000000..1ab86904 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/LockerService.java @@ -0,0 +1,25 @@ +package com.eternalcode.parcellockers.locker; + +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; +import com.eternalcode.parcellockers.shared.Position; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface LockerService { + + CompletableFuture> get(UUID uniqueId); + + CompletableFuture> get(Position position); + + CompletableFuture> get(Page page); + + CompletableFuture create(UUID uniqueId, String name, Position position, UUID playerUuid); + + CompletableFuture delete(UUID uniqueId, UUID playerUuid); + + CompletableFuture rename(UUID uniqueId, String newName); + + CompletableFuture isLockerFull(UUID uniqueId); +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java similarity index 90% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java index 014670d1..1a89ab33 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/LockerCreateEvent.java @@ -4,7 +4,6 @@ import com.eternalcode.parcellockers.shared.event.CancellableEvent; import java.util.UUID; import org.bukkit.event.HandlerList; -import org.jetbrains.annotations.NotNull; public class LockerCreateEvent extends CancellableEvent { @@ -32,7 +31,7 @@ public UUID getPlayer() { } @Override - public @NotNull HandlerList getHandlers() { + public HandlerList getHandlers() { return HANDLER_LIST; } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java similarity index 92% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java index 281dfc4c..8c02fe84 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/LockerDeleteEvent.java @@ -4,7 +4,6 @@ import com.eternalcode.parcellockers.shared.event.CancellableEvent; import java.util.UUID; import org.bukkit.event.HandlerList; -import org.jetbrains.annotations.NotNull; // Called when a locker is deleted. Fired synchronously on the main thread (see LockerManager#delete). // Warning: this event is not called when all lockers are deleted through "/parcel debug delete lockers" command @@ -33,7 +32,7 @@ public UUID getPlayer() { } @Override - public @NotNull HandlerList getHandlers() { + public HandlerList getHandlers() { return HANDLER_LIST; } } diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/package-info.java new file mode 100644 index 00000000..46e31349 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/event/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.locker.event; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/package-info.java new file mode 100644 index 00000000..aa90c644 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/locker/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.locker; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/package-info.java new file mode 100644 index 00000000..5666a0fb --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java similarity index 88% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java index 45fbc8ff..5bd15c52 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelCollectEvent.java @@ -3,7 +3,6 @@ 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 ParcelCollectEvent extends CancellableEvent { @@ -24,7 +23,7 @@ public Parcel getParcel() { } @Override - public @NotNull HandlerList getHandlers() { + public HandlerList getHandlers() { return HANDLER_LIST; } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java similarity index 88% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java index dd8a9855..da40b3da 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelDeliverEvent.java @@ -3,7 +3,6 @@ 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 ParcelDeliverEvent extends CancellableEvent { @@ -25,7 +24,7 @@ public Parcel getParcel() { } @Override - public @NotNull HandlerList getHandlers() { + public HandlerList getHandlers() { return HANDLER_LIST; } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java similarity index 88% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java index c388b0d4..53935fc9 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java @@ -3,7 +3,6 @@ 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 { @@ -24,7 +23,7 @@ public Parcel getParcel() { } @Override - public @NotNull HandlerList getHandlers() { + public HandlerList getHandlers() { return HANDLER_LIST; } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java similarity index 88% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java index 68395516..70352318 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelSendEvent.java @@ -3,7 +3,6 @@ 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 ParcelSendEvent extends CancellableEvent { @@ -25,7 +24,7 @@ public Parcel getParcel() { } @Override - public @NotNull HandlerList getHandlers() { + public HandlerList getHandlers() { return HANDLER_LIST; } } diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/package-info.java new file mode 100644 index 00000000..35bead0d --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/event/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.parcel.event; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/package-info.java new file mode 100644 index 00000000..1fb19dcf --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.parcel; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java similarity index 50% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java index 26857b40..c39b8176 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -1,6 +1,5 @@ package com.eternalcode.parcellockers.parcel.service; -import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; @@ -9,51 +8,32 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.Nullable; public interface ParcelService { CompletableFuture send(Player sender, Parcel parcel, List items); - /** - * Rolls back a parcel that was successfully persisted by {@link #send} but could not be - * fully dispatched. Deletes both the parcel and its content and refunds the send fee. - */ - CompletableFuture rollbackSend(Player sender, Parcel parcel); - 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); - - CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService); - CompletableFuture> get(UUID uuid); - void invalidate(UUID uuid); - CompletableFuture> getBySender(UUID sender, Page page); CompletableFuture> getByReceiver(UUID receiver, Page page); - /** - * Returns the delivered parcels a receiver may collect, optionally restricted to a single - * destination locker. Filtering is applied in the query so pagination stays consistent. - * - * @param destinationLocker the locker to collect from, or null to allow any locker - */ - CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page); + CompletableFuture> getCollectible( + UUID receiver, + @Nullable UUID destinationLocker, + Page page + ); - /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ CompletableFuture> getReturnable(UUID receiver, Page page); CompletableFuture> getAll(Page page); @@ -61,5 +41,4 @@ public interface ParcelService { CompletableFuture delete(UUID uuid); CompletableFuture delete(Parcel parcel); - } diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/package-info.java new file mode 100644 index 00000000..c29a25b0 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/service/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.parcel.service; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Page.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/Page.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Page.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/Page.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/PageResult.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Position.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/Position.java similarity index 90% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Position.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/Position.java index 6a43f3b5..77a5e978 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/Position.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/Position.java @@ -4,9 +4,7 @@ import java.util.regex.Pattern; /** - * Disclaimer - Bukkit {@link org.bukkit.Location} storage may cause a memory leak because it is a wrapper for - * coordinates and {@link org.bukkit.World} reference. If you need to store location, use {@link Position} and - * {@link PositionAdapter}. + * Immutable coordinates suitable for storage without retaining a Bukkit world instance. */ public record Position(int x, int y, int z, String world) { diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/event/CancellableEvent.java diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/event/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/event/package-info.java new file mode 100644 index 00000000..728e87ee --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/event/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.shared.event; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelLockersException.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/ParcelOperationException.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java similarity index 100% rename from parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/ValidationException.java diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/package-info.java new file mode 100644 index 00000000..b2a6fee3 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/exception/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.shared.exception; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/package-info.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/package-info.java new file mode 100644 index 00000000..0d04e511 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.eternalcode.parcellockers.shared; + +import org.jspecify.annotations.NullMarked; diff --git a/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java new file mode 100644 index 00000000..c5aab6f2 --- /dev/null +++ b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java @@ -0,0 +1,41 @@ +package com.eternalcode.parcellockers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class ApiSurfaceTest { + + @Test + void exposesOnlySupportedParcelOperations() { + Set methods = methodNames(ParcelService.class); + + assertEquals(Set.of( + "send", "update", "updateIfStatus", "collect", "get", + "getBySender", "getByReceiver", "getCollectible", "getReturnable", + "getAll", "delete" + ), methods); + assertFalse(methods.contains("rollbackSend")); + assertFalse(methods.contains("invalidate")); + assertFalse(methods.contains("deleteAll")); + } + + @Test + void exposesOnlySupportedLockerOperations() { + assertEquals(Set.of("get", "create", "delete", "rename", "isLockerFull"), + methodNames(LockerService.class)); + } + + private static Set methodNames(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .map(Method::getName) + .collect(Collectors.toSet()); + } +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 12ef2ba9..f9399e2f 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -41,8 +41,8 @@ import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; import com.eternalcode.parcellockers.parcel.service.AdminParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; -import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; import com.eternalcode.parcellockers.returns.ParcelReturnService; import com.eternalcode.parcellockers.returns.ParcelReturnValidator; @@ -134,7 +134,7 @@ public void onEnable() { new ParcelReturnRepositoryOrmLite(databaseManager, scheduler); // service and managers - ParcelService parcelService = new ParcelServiceImpl( + PluginParcelService parcelService = new ParcelServiceImpl( noticeService, parcelRepository, parcelContentRepository, diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java index 9366d284..88db816b 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/command/debug/DebugCommand.java @@ -7,7 +7,7 @@ import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; import com.eternalcode.parcellockers.locker.LockerManager; import com.eternalcode.parcellockers.notification.NoticeService; -import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import dev.rollczi.litecommands.annotations.argument.Arg; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Sender; @@ -27,7 +27,7 @@ @Permission("parcellockers.debug") public class DebugCommand { - private final ParcelService parcelService; + private final PluginParcelService parcelService; private final LockerManager lockerManager; private final ItemStorageManager itemStorageManager; private final ParcelContentManager contentManager; @@ -35,7 +35,7 @@ public class DebugCommand { private final DeliveryManager deliveryManager; public DebugCommand( - ParcelService parcelService, + PluginParcelService parcelService, LockerManager lockerManager, ItemStorageManager itemStorageManager, ParcelContentManager contentManager, diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 54666dc9..1099550e 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -11,7 +11,7 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; -import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import com.eternalcode.parcellockers.returns.CollectedParcel; import com.eternalcode.parcellockers.returns.ParcelReturnService; import com.eternalcode.parcellockers.shared.Page; @@ -30,7 +30,7 @@ public class GuiManager { - private final ParcelService parcelService; + private final PluginParcelService parcelService; private final LockerManager lockerManager; private final UserManager userManager; private final ItemStorageManager itemStorageManager; @@ -42,7 +42,7 @@ public class GuiManager { private final Duration returnWindow; public GuiManager( - ParcelService parcelService, + PluginParcelService parcelService, LockerManager lockerManager, UserManager userManager, ItemStorageManager itemStorageManager, diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 9cf928a6..5fee6720 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -23,7 +23,7 @@ public class ParcelDispatchService { private static final Logger LOGGER = Logger.getLogger(ParcelDispatchService.class.getName()); private final LockerManager lockerManager; - private final ParcelService parcelService; + private final PluginParcelService parcelService; private final DeliveryManager deliveryManager; private final ItemStorageManager itemStorageManager; private final Scheduler scheduler; @@ -36,7 +36,7 @@ public class ParcelDispatchService { public ParcelDispatchService( LockerManager lockerManager, - ParcelService parcelService, + PluginParcelService parcelService, DeliveryManager deliveryManager, ItemStorageManager itemStorageManager, Scheduler scheduler, diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index fbfe5778..5cc90530 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -35,7 +35,7 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -public class ParcelServiceImpl implements ParcelService { +public class ParcelServiceImpl implements PluginParcelService { private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java new file mode 100644 index 00000000..ef3440b8 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java @@ -0,0 +1,19 @@ +package com.eternalcode.parcellockers.parcel.service; + +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public interface PluginParcelService extends ParcelService { + + CompletableFuture rollbackSend(Player sender, Parcel parcel); + + void invalidate(UUID uuid); + + CompletableFuture delete(CommandSender sender, Parcel parcel); + + CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService); +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index daf55375..60c2d13c 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -15,7 +15,7 @@ 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.service.PluginParcelService; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; @@ -44,7 +44,7 @@ public class ParcelReturnService { private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; - private final ParcelService parcelService; + private final PluginParcelService parcelService; private final ParcelContentManager parcelContentManager; private final CollectedParcelRepository collectedParcelRepository; private final DeliveryManager deliveryManager; @@ -59,7 +59,7 @@ public class ParcelReturnService { private final Server server; public ParcelReturnService( - ParcelService parcelService, + PluginParcelService parcelService, ParcelContentManager parcelContentManager, CollectedParcelRepository collectedParcelRepository, DeliveryManager deliveryManager, diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index 1ba75c21..7261e1ab 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -27,7 +27,7 @@ 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.parcel.service.PluginParcelService; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; import java.time.Duration; @@ -172,7 +172,7 @@ 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 PluginParcelService parcelService = mock(PluginParcelService.class); private final ParcelContentManager contentManager = mock(ParcelContentManager.class); private final CollectedParcelRepository collectedRepository = mock(CollectedParcelRepository.class); private final DeliveryManager deliveryManager = mock(DeliveryManager.class); From 9841eb0d115abad945df4c1dd241533b060d45bb Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:28:47 +0200 Subject: [PATCH 06/19] feat: expose locker service implementation --- .../parcellockers/locker/LockerManager.java | 11 ++++- .../locker/LockerManagerTest.java | 45 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java index 05e5f814..95af7927 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java @@ -11,10 +11,10 @@ import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.Position; +import com.eternalcode.parcellockers.shared.exception.ValidationException; import com.eternalcode.parcellockers.shared.validation.ValidationResult; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import eu.okaeri.configs.exception.ValidationException; import java.time.Duration; import java.util.Optional; import java.util.UUID; @@ -23,7 +23,7 @@ import org.bukkit.Server; import org.bukkit.command.CommandSender; -public class LockerManager { +public class LockerManager implements LockerService { private final PluginConfig config; private final LockerRepository lockerRepository; @@ -60,6 +60,7 @@ public LockerManager( .build(); } + @Override public CompletableFuture> get(UUID uniqueId) { if (uniqueId == null) { return CompletableFuture.completedFuture(Optional.empty()); @@ -89,6 +90,7 @@ public Optional getCached(Position position) { return Optional.ofNullable(this.lockersByPosition.getIfPresent(position)); } + @Override public CompletableFuture> get(Position position) { Locker locker = this.lockersByPosition.getIfPresent(position); @@ -105,6 +107,7 @@ public CompletableFuture> get(Position position) { }); } + @Override public CompletableFuture> get(Page page) { // The cache holds an arbitrary, partially-evicted subset of lockers - it is not the full // dataset, so it cannot answer pagination. Always query the repository (warming the cache @@ -118,6 +121,7 @@ public CompletableFuture> get(Page page) { }); } + @Override public CompletableFuture create(UUID uniqueId, String name, Position position, UUID playerUUID) { return CompletableFuture.supplyAsync(() -> { @@ -162,6 +166,7 @@ public CompletableFuture create(UUID uniqueId, String name, Position pos }).thenCompose(Function.identity()); } + @Override public CompletableFuture delete(UUID uniqueId, UUID playerUUID) { Locker cachedLocker = this.lockersByUUID.getIfPresent(uniqueId); @@ -208,6 +213,7 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not }); } + @Override public CompletableFuture rename(UUID uniqueId, String newName) { return this.get(uniqueId).thenCompose(optional -> { if (optional.isEmpty()) { @@ -226,6 +232,7 @@ public CompletableFuture rename(UUID uniqueId, String newName) { }); } + @Override public CompletableFuture isLockerFull(UUID uniqueId) { return this.parcelRepository.countParcelsByDestinationLocker(uniqueId) .thenApply(count -> count >= this.config.settings.maxParcelsPerLocker); diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java new file mode 100644 index 00000000..a419b542 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java @@ -0,0 +1,45 @@ +package com.eternalcode.parcellockers.locker; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.locker.repository.LockerRepository; +import com.eternalcode.parcellockers.locker.validation.LockerValidationService; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.shared.Position; +import com.eternalcode.parcellockers.shared.exception.ValidationException; +import com.eternalcode.parcellockers.shared.validation.ValidationResult; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; + +class LockerManagerTest { + + @Test + void exposesApiValidationExceptionForInvalidCreateRequest() { + LockerValidationService validation = mock(LockerValidationService.class); + UUID lockerId = UUID.randomUUID(); + UUID playerId = UUID.randomUUID(); + Position position = new Position(1, 2, 3, "world"); + when(validation.validateCreateParameters(lockerId, "locker", position)) + .thenReturn(ValidationResult.invalid("invalid locker")); + + LockerManager manager = new LockerManager( + null, + mock(LockerRepository.class), + validation, + mock(ParcelRepository.class), + mock(Server.class), + mock(Scheduler.class) + ); + + CompletionException exception = assertThrows(CompletionException.class, + () -> manager.create(lockerId, "locker", position, playerId).join()); + + assertInstanceOf(ValidationException.class, exception.getCause()); + } +} From e60fa903323f50556663e707ac7e30c7381364ee Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:22:37 +0200 Subject: [PATCH 07/19] feat: expose ParcelLockers API provider --- .../parcellockers/ParcelLockersApi.java | 11 +++ .../parcellockers/ParcelLockersProvider.java | 34 ++++++++++ .../ParcelLockersProviderTest.java | 67 +++++++++++++++++++ .../parcellockers/ParcelLockers.java | 28 +++++++- 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersApi.java create mode 100644 parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersProvider.java create mode 100644 parcellockers-api/src/test/java/com/eternalcode/parcellockers/ParcelLockersProviderTest.java diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersApi.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersApi.java new file mode 100644 index 00000000..a11adbf4 --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersApi.java @@ -0,0 +1,11 @@ +package com.eternalcode.parcellockers; + +import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; + +public interface ParcelLockersApi { + + ParcelService getParcelService(); + + LockerService getLockerService(); +} diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersProvider.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersProvider.java new file mode 100644 index 00000000..066787ed --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/ParcelLockersProvider.java @@ -0,0 +1,34 @@ +package com.eternalcode.parcellockers; + +import java.util.Objects; + +public final class ParcelLockersProvider { + + private static volatile ParcelLockersApi api; + + private ParcelLockersProvider() { + } + + public static ParcelLockersApi provide() { + ParcelLockersApi current = api; + if (current == null) { + throw new IllegalStateException("ParcelLockersApi has not been initialized"); + } + return current; + } + + static synchronized void initialize(ParcelLockersApi parcelLockersApi) { + Objects.requireNonNull(parcelLockersApi, "parcelLockersApi"); + if (api != null) { + throw new IllegalStateException("ParcelLockersApi has already been initialized"); + } + api = parcelLockersApi; + } + + static synchronized void deinitialize() { + if (api == null) { + throw new IllegalStateException("ParcelLockersApi has not been initialized"); + } + api = null; + } +} diff --git a/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ParcelLockersProviderTest.java b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ParcelLockersProviderTest.java new file mode 100644 index 00000000..b14ebe53 --- /dev/null +++ b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ParcelLockersProviderTest.java @@ -0,0 +1,67 @@ +package com.eternalcode.parcellockers; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ParcelLockersProviderTest { + + @AfterEach + void resetProvider() { + try { + ParcelLockersProvider.deinitialize(); + } catch (IllegalStateException ignored) { + // The test already left the provider empty. + } + } + + @Test + void rejectsAccessBeforeInitialization() { + assertThrows(IllegalStateException.class, ParcelLockersProvider::provide); + } + + @Test + void returnsInitializedApi() { + ParcelLockersApi api = stubApi(); + + ParcelLockersProvider.initialize(api); + + assertSame(api, ParcelLockersProvider.provide()); + } + + @Test + void rejectsDuplicateInitialization() { + ParcelLockersProvider.initialize(stubApi()); + + assertThrows(IllegalStateException.class, + () -> ParcelLockersProvider.initialize(stubApi())); + } + + @Test + void rejectsAccessAfterDeinitialization() { + ParcelLockersProvider.initialize(stubApi()); + + ParcelLockersProvider.deinitialize(); + + assertThrows(IllegalStateException.class, ParcelLockersProvider::provide); + assertThrows(IllegalStateException.class, ParcelLockersProvider::deinitialize); + } + + private static ParcelLockersApi stubApi() { + return new ParcelLockersApi() { + @Override + public ParcelService getParcelService() { + throw new UnsupportedOperationException(); + } + + @Override + public LockerService getLockerService() { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index f9399e2f..34b0a7f2 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -29,6 +29,7 @@ import com.eternalcode.parcellockers.itemstorage.repository.ItemStorageRepository; import com.eternalcode.parcellockers.itemstorage.repository.ItemStorageRepositoryOrmLite; import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.locker.LockerService; import com.eternalcode.parcellockers.locker.controller.LockerBreakController; import com.eternalcode.parcellockers.locker.controller.LockerInteractionController; import com.eternalcode.parcellockers.locker.controller.LockerPlaceController; @@ -41,6 +42,7 @@ import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; import com.eternalcode.parcellockers.parcel.service.AdminParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; @@ -74,6 +76,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.Objects; import java.util.stream.Stream; import net.kyori.adventure.text.minimessage.MiniMessage; import net.milkbowl.vault.economy.Economy; @@ -84,12 +87,15 @@ import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.java.JavaPlugin; -public final class ParcelLockers extends JavaPlugin { +public final class ParcelLockers extends JavaPlugin implements ParcelLockersApi { private LiteCommands liteCommands; private DatabaseManager databaseManager; private Economy economy; private DiscordClientManager discordClientManager; + private ParcelService parcelService; + private LockerService lockerService; + private boolean apiInitialized; @Override public void onEnable() { @@ -149,6 +155,8 @@ public void onEnable() { UserManager userManager = new UserManagerImpl(userRepository, userValidationService, server); LockerValidationService lockerValidationService = new LockerValidator(); LockerManager lockerManager = new LockerManager(config, lockerRepository, lockerValidationService, parcelRepository, server, scheduler); + this.parcelService = parcelService; + this.lockerService = lockerManager; ParcelContentManager parcelContentManager = new ParcelContentManager(parcelContentRepository); ItemStorageManager itemStorageManager = new ItemStorageManager(itemStorageRepository, server); DeliveryManager deliveryManager = new DeliveryManager(deliveryRepository); @@ -274,10 +282,18 @@ public void onEnable() { Duration.ofMillis(delay)); }) ))); + + ParcelLockersProvider.initialize(this); + this.apiInitialized = true; } @Override public void onDisable() { + if (this.apiInitialized) { + ParcelLockersProvider.deinitialize(); + this.apiInitialized = false; + } + // Stop accepting new work before closing the datasource, so fewer in-flight async DB tasks // run into an already-closed connection pool. HandlerList.unregisterAll(this); @@ -296,6 +312,16 @@ public void onDisable() { } } + @Override + public ParcelService getParcelService() { + return Objects.requireNonNull(this.parcelService, "ParcelService is not initialized"); + } + + @Override + public LockerService getLockerService() { + return Objects.requireNonNull(this.lockerService, "LockerService is not initialized"); + } + private boolean setupEconomy() { if (this.getServer().getPluginManager().getPlugin("Vault") == null) { return false; From 57c52e1c7232b24d118efd737aa942f80372da32 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:31:28 +0200 Subject: [PATCH 08/19] build: publish ParcelLockers API artifact --- parcellockers-api/build.gradle.kts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/parcellockers-api/build.gradle.kts b/parcellockers-api/build.gradle.kts index dd4dbcd8..3d9a41e6 100644 --- a/parcellockers-api/build.gradle.kts +++ b/parcellockers-api/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `java-library` + `maven-publish` } dependencies { @@ -11,3 +12,29 @@ dependencies { testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT}") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } + +java { + withSourcesJar() + withJavadocJar() +} + +publishing { + publications { + create("maven") { + artifactId = "parcellockers-api" + from(components["java"]) + } + } + + repositories { + mavenLocal() + maven { + name = "eternalcodeReleases" + url = uri("https://repo.eternalcode.pl/releases") + credentials { + username = System.getenv("ETERNAL_CODE_MAVEN_USERNAME") + password = System.getenv("ETERNAL_CODE_MAVEN_PASSWORD") + } + } + } +} From d2b29a2992718a1e48c799bae8d7bb86fea50b0e Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:38:24 +0200 Subject: [PATCH 09/19] fix: route public sends through dispatch workflow --- .../parcellockers/parcel/Parcel.java | 3 +- .../parcellockers/ApiSurfaceTest.java | 15 ++ .../parcellockers/ParcelLockers.java | 2 +- .../parcellockers/PublicParcelService.java | 113 +++++++++++ .../delivery/DeliveryManager.java | 11 +- .../parcellockers/locker/LockerManager.java | 7 +- .../parcel/service/ParcelDispatchService.java | 60 +++--- .../PublicParcelServiceTest.java | 49 +++++ .../locker/LockerManagerTest.java | 64 +++++++ .../service/ParcelDispatchServiceTest.java | 175 ++++++++++++++++++ 10 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java index c248c7ed..d083ee5e 100644 --- a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java @@ -1,12 +1,13 @@ package com.eternalcode.parcellockers.parcel; import java.util.UUID; +import org.jspecify.annotations.Nullable; public record Parcel( UUID uuid, UUID sender, String name, - String description, + @Nullable String description, boolean priority, UUID receiver, ParcelSize size, diff --git a/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java index c5aab6f2..c6d0843f 100644 --- a/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java +++ b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java @@ -2,14 +2,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import com.eternalcode.parcellockers.locker.LockerService; +import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.service.ParcelService; import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import org.jspecify.annotations.Nullable; class ApiSurfaceTest { @@ -33,6 +37,17 @@ void exposesOnlySupportedLockerOperations() { methodNames(LockerService.class)); } + @Test + void parcelDescriptionIsExplicitlyNullable() { + RecordComponent description = Arrays.stream(Parcel.class.getRecordComponents()) + .filter(component -> component.getName().equals("description")) + .findFirst() + .orElseThrow(); + + assertNotNull(description.getAnnotatedType().getAnnotation(Nullable.class)); + assertNotNull(description.getAccessor().getAnnotatedReturnType().getAnnotation(Nullable.class)); + } + private static Set methodNames(Class type) { return Arrays.stream(type.getDeclaredMethods()) .map(Method::getName) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 34b0a7f2..028a133a 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -155,7 +155,6 @@ public void onEnable() { UserManager userManager = new UserManagerImpl(userRepository, userValidationService, server); LockerValidationService lockerValidationService = new LockerValidator(); LockerManager lockerManager = new LockerManager(config, lockerRepository, lockerValidationService, parcelRepository, server, scheduler); - this.parcelService = parcelService; this.lockerService = lockerManager; ParcelContentManager parcelContentManager = new ParcelContentManager(parcelContentRepository); ItemStorageManager itemStorageManager = new ItemStorageManager(itemStorageRepository, server); @@ -170,6 +169,7 @@ public void onEnable() { config, noticeService ); + this.parcelService = new PublicParcelService(parcelService, parcelDispatchService, scheduler); ParcelReturnValidator returnValidator = new ParcelReturnValidator(new ReturnItemEquivalence(config.settings.returnChecks)); ReturnMismatchFormatter returnMismatchFormatter = new ReturnMismatchFormatter(messageConfig.parcel); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java new file mode 100644 index 00000000..39a117b5 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java @@ -0,0 +1,113 @@ +package com.eternalcode.parcellockers; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.Nullable; + +final class PublicParcelService implements ParcelService { + + private final PluginParcelService delegate; + private final ParcelDispatchService dispatcher; + private final Scheduler scheduler; + + PublicParcelService( + PluginParcelService delegate, + ParcelDispatchService dispatcher, + Scheduler scheduler + ) { + this.delegate = delegate; + this.dispatcher = dispatcher; + this.scheduler = scheduler; + } + + @Override + public CompletableFuture send(Player sender, Parcel parcel, List items) { + CompletableFuture result = new CompletableFuture<>(); + this.scheduler.runAsync(() -> { + try { + this.dispatcher.dispatch(sender, parcel, items) + .whenComplete((success, throwable) -> { + if (throwable != null) { + result.completeExceptionally(throwable); + } else { + result.complete(success); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally(throwable); + } + }); + return result; + } + + @Override + public CompletableFuture update(Parcel parcel) { + return this.delegate.update(parcel); + } + + @Override + public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { + return this.delegate.updateIfStatus(updated, expectedStatus); + } + + @Override + public CompletableFuture collect(Player player, Parcel parcel) { + return this.delegate.collect(player, parcel); + } + + @Override + public CompletableFuture> get(UUID uuid) { + return this.delegate.get(uuid); + } + + @Override + public CompletableFuture> getBySender(UUID sender, Page page) { + return this.delegate.getBySender(sender, page); + } + + @Override + public CompletableFuture> getByReceiver(UUID receiver, Page page) { + return this.delegate.getByReceiver(receiver, page); + } + + @Override + public CompletableFuture> getCollectible( + UUID receiver, + @Nullable UUID destinationLocker, + Page page + ) { + return this.delegate.getCollectible(receiver, destinationLocker, page); + } + + @Override + public CompletableFuture> getReturnable(UUID receiver, Page page) { + return this.delegate.getReturnable(receiver, page); + } + + @Override + public CompletableFuture> getAll(Page page) { + return this.delegate.getAll(page); + } + + @Override + public CompletableFuture delete(UUID uuid) { + return this.delegate.delete(uuid); + } + + @Override + public CompletableFuture delete(Parcel parcel) { + return this.delegate.delete(parcel); + } +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java index 265136f5..144df31e 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java @@ -47,14 +47,19 @@ public CompletableFuture update(UUID parcel, Instant deliveryTimestamp }); } - public Delivery create(UUID parcel, Instant deliveryTimestamp) { + public CompletableFuture create(UUID parcel, Instant deliveryTimestamp) { Delivery delivery = new Delivery(parcel, deliveryTimestamp); if (this.deliveryCache.getIfPresent(parcel) != null) { throw new IllegalStateException("Delivery for parcel " + parcel + " already exists. Use Delivery#getOrCreate method instead."); } this.deliveryCache.put(parcel, delivery); - this.deliveryRepository.save(delivery); - return delivery; + return this.deliveryRepository.save(delivery) + .whenComplete((saved, throwable) -> { + if (throwable != null) { + this.deliveryCache.invalidate(parcel); + } + }) + .thenApply(ignored -> delivery); } public CompletableFuture delete(UUID parcel) { diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java index 95af7927..8ea681a6 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java @@ -215,10 +215,15 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not @Override public CompletableFuture rename(UUID uniqueId, String newName) { + if (newName == null || newName.isBlank()) { + return CompletableFuture.failedFuture( + new ValidationException("Locker name cannot be null or blank")); + } + return this.get(uniqueId).thenCompose(optional -> { if (optional.isEmpty()) { return CompletableFuture.failedFuture( - new IllegalArgumentException("Locker not found: " + uniqueId)); + new ValidationException("Locker not found: " + uniqueId)); } Locker existing = optional.get(); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 5fee6720..531feec2 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -32,7 +32,7 @@ public class ParcelDispatchService { // Serializes dispatches per destination locker so that two concurrent sends cannot both pass // the fullness check before either parcel is persisted (a TOCTOU that could exceed the cap). - private final ConcurrentHashMap> lockerChains = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> lockerChains = new ConcurrentHashMap<>(); public ParcelDispatchService( LockerManager lockerManager, @@ -52,11 +52,11 @@ public ParcelDispatchService( this.noticeService = noticeService; } - public void dispatch(Player sender, Parcel parcel, List items) { + public CompletableFuture dispatch(Player sender, Parcel parcel, List items) { UUID lockerId = parcel.destinationLocker(); - CompletableFuture chained = this.lockerChains.compute(lockerId, (id, previous) -> { - CompletableFuture predecessor = previous == null + CompletableFuture chained = this.lockerChains.compute(lockerId, (id, previous) -> { + CompletableFuture predecessor = previous == null ? CompletableFuture.completedFuture(null) : previous.exceptionally(throwable -> null); return predecessor.thenCompose(ignored -> this.dispatchInternal(sender, parcel, items)); @@ -64,14 +64,15 @@ public void dispatch(Player sender, Parcel parcel, List items) { // Drop the chain entry once it drains so the map does not grow unbounded. chained.whenComplete((result, throwable) -> this.lockerChains.remove(lockerId, chained)); + return chained; } - private CompletableFuture dispatchInternal(Player sender, Parcel parcel, List items) { + private CompletableFuture dispatchInternal(Player sender, Parcel parcel, List items) { return this.lockerManager.isLockerFull(parcel.destinationLocker()) .thenCompose(isFull -> { if (isFull) { this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.lockerFull); - return CompletableFuture.completedFuture(null); + return CompletableFuture.completedFuture(false); } Duration delay = parcel.priority() @@ -82,7 +83,7 @@ private CompletableFuture dispatchInternal(Player sender, Parcel parcel, L .thenCompose(success -> { if (!Boolean.TRUE.equals(success)) { this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return CompletableFuture.completedFuture(null); + return CompletableFuture.completedFuture(false); } return this.itemStorageManager.delete(sender.getUniqueId()) @@ -95,30 +96,45 @@ private CompletableFuture dispatchInternal(Player sender, Parcel parcel, L // but the sender's staged storage could not be cleared. Fully roll back // (parcel + content + fee) instead of leaving orphaned content behind. this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return this.parcelService.rollbackSend(sender, parcel); + return this.parcelService.rollbackSend(sender, parcel) + .thenApply(ignored -> false); } - this.deliveryManager.create(parcel.uuid(), Instant.now().plus(delay)); - - ParcelSendTask task = new ParcelSendTask( - parcel, - this.parcelService, - this.deliveryManager, - this.scheduler - ); + CompletableFuture deliveryCreated; + try { + deliveryCreated = this.deliveryManager.create( + parcel.uuid(), + Instant.now().plus(delay) + ); + } catch (Throwable throwable) { + deliveryCreated = CompletableFuture.failedFuture(throwable); + } - this.scheduler.runLaterAsync(task, delay); - // Only confirm success here, once every step has succeeded, to avoid a - // "sent" notice immediately followed by "cannot send" on a rollback. - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.sent); - return CompletableFuture.completedFuture(null); + return deliveryCreated + .thenApply(delivery -> { + ParcelSendTask task = new ParcelSendTask( + parcel, + this.parcelService, + this.deliveryManager, + this.scheduler + ); + + this.scheduler.runLaterAsync(task, delay); + // Only confirm success here, once every step has succeeded, to avoid a + // "sent" notice immediately followed by "cannot send" on a rollback. + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.sent); + return true; + }) + .exceptionallyCompose(throwable -> + this.parcelService.rollbackSend(sender, parcel) + .thenApply(ignored -> false)); }); }); }) .exceptionally(throwable -> { LOGGER.severe("Failed to dispatch parcel for player " + sender.getName() + ": " + throwable.getMessage()); this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return null; + return false; }); } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java new file mode 100644 index 00000000..3f36d421 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java @@ -0,0 +1,49 @@ +package com.eternalcode.parcellockers; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +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.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class PublicParcelServiceTest { + + @Test + void sendDefersTheCompleteDispatchWorkflowOffTheCallingThread() { + PluginParcelService delegate = mock(PluginParcelService.class); + ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); + Scheduler scheduler = mock(Scheduler.class); + PublicParcelService service = new PublicParcelService(delegate, dispatcher, scheduler); + Player sender = mock(Player.class); + Parcel parcel = mock(Parcel.class); + List items = List.of(mock(ItemStack.class)); + CompletableFuture dispatched = new CompletableFuture<>(); + when(dispatcher.dispatch(sender, parcel, items)).thenReturn(dispatched); + + CompletableFuture result = service.send(sender, parcel, items); + + assertFalse(result.isDone()); + verify(dispatcher, never()).dispatch(sender, parcel, items); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runAsync(task.capture()); + task.getValue().run(); + + verify(dispatcher).dispatch(sender, parcel, items); + dispatched.complete(true); + assertSame(Boolean.TRUE, result.join()); + verify(delegate, never()).send(sender, parcel, items); + } +} diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java index a419b542..ceeae766 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java @@ -1,8 +1,12 @@ package com.eternalcode.parcellockers.locker; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.eternalcode.commons.scheduler.Scheduler; @@ -13,6 +17,8 @@ import com.eternalcode.parcellockers.shared.exception.ValidationException; import com.eternalcode.parcellockers.shared.validation.ValidationResult; import java.util.UUID; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.bukkit.Server; import org.junit.jupiter.api.Test; @@ -42,4 +48,62 @@ void exposesApiValidationExceptionForInvalidCreateRequest() { assertInstanceOf(ValidationException.class, exception.getCause()); } + + @Test + void rejectsBlankRenameAsAnExceptionalFutureBeforePersistence() { + LockerRepository repository = mock(LockerRepository.class); + LockerManager manager = manager(repository); + UUID lockerId = UUID.randomUUID(); + + CompletableFuture future = assertDoesNotThrow(() -> manager.rename(lockerId, " ")); + CompletionException exception = assertThrows(CompletionException.class, future::join); + + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(repository, never()).find(lockerId); + verify(repository, never()).update(org.mockito.ArgumentMatchers.any()); + } + + @Test + void reportsMissingRenameTargetWithApiValidationException() { + LockerRepository repository = mock(LockerRepository.class); + LockerManager manager = manager(repository); + UUID lockerId = UUID.randomUUID(); + when(repository.find(lockerId)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + CompletableFuture future = assertDoesNotThrow(() -> manager.rename(lockerId, "renamed")); + CompletionException exception = assertThrows(CompletionException.class, future::join); + + assertInstanceOf(ValidationException.class, exception.getCause()); + } + + @Test + void successfulRenamePersistsAndRefreshesBothCaches() { + LockerRepository repository = mock(LockerRepository.class); + LockerManager manager = manager(repository); + UUID lockerId = UUID.randomUUID(); + Position position = new Position(1, 2, 3, "world"); + Locker existing = new Locker(lockerId, "old", position); + Locker renamed = new Locker(lockerId, "new", position); + when(repository.find(lockerId)).thenReturn(CompletableFuture.completedFuture(Optional.of(existing))); + when(repository.update(renamed)).thenReturn(CompletableFuture.completedFuture(renamed)); + + assertEquals(renamed, manager.rename(lockerId, "new").join()); + assertEquals(Optional.of(renamed), manager.get(lockerId).join()); + assertEquals(Optional.of(renamed), manager.get(position).join()); + + verify(repository).update(renamed); + verify(repository).find(lockerId); + verify(repository, never()).find(position); + } + + private static LockerManager manager(LockerRepository repository) { + return new LockerManager( + null, + repository, + mock(LockerValidationService.class), + mock(ParcelRepository.class), + mock(Server.class), + mock(Scheduler.class) + ); + } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java new file mode 100644 index 00000000..38ae07b9 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -0,0 +1,175 @@ +package com.eternalcode.parcellockers.parcel.service; + +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.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.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; +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 java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ParcelDispatchServiceTest { + + private final LockerManager lockerManager = mock(LockerManager.class); + private final PluginParcelService parcelService = mock(PluginParcelService.class); + private final DeliveryManager deliveryManager = mock(DeliveryManager.class); + private final ItemStorageManager itemStorageManager = mock(ItemStorageManager.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final NoticeService noticeService = mock(NoticeService.class); + private final PluginConfig config = new PluginConfig(); + private final Player sender = mock(Player.class); + private final Parcel parcel = parcel(); + private final List items = List.of(mock(ItemStack.class)); + private final ParcelDispatchService dispatcher = new ParcelDispatchService( + this.lockerManager, + this.parcelService, + this.deliveryManager, + this.itemStorageManager, + this.scheduler, + this.config, + this.noticeService + ); + + @BeforeEach + void setUp() { + this.config.settings.parcelSendDuration = Duration.ofMinutes(30); + when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); + } + + @Test + void successCompletesOnlyAfterDeliveryIsPersistedAndTaskIsScheduled() { + CompletableFuture deliveryCreated = new CompletableFuture<>(); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(deliveryCreated); + + CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); + + assertFalse(result.isDone()); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + + deliveryCreated.complete(mock(Delivery.class)); + + assertTrue(result.join()); + verify(this.scheduler).runLaterAsync(any(), eq(this.config.settings.parcelSendDuration)); + } + + @Test + void fullLockerCompletesFalseWithoutPersistingParcel() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(true)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.parcelService, never()).send(any(), any(), any()); + } + + @Test + void cancelledPersistenceStepCompletesFalseWithoutContinuing() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(false)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.itemStorageManager, never()).delete(any()); + } + + @Test + void storageDeleteFailureRollsBackAndCompletesFalse() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("delete failed"))); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.deliveryManager, never()).create(any(), any()); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + } + + @Test + void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenThrow(new IllegalStateException("create failed")); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + } + + @Test + void asynchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + } + + private static Parcel parcel() { + return new Parcel( + UUID.randomUUID(), + UUID.randomUUID(), + "name", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.SENT + ); + } +} From 56b3d153a90c4e15a0802a3980da07f9d1200796 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:48:57 +0200 Subject: [PATCH 10/19] fix: compensate failed parcel dispatch setup --- .../parcellockers/PublicParcelService.java | 34 ++-- .../parcel/service/ParcelDispatchService.java | 181 ++++++++++++++---- .../PublicParcelServiceTest.java | 22 +++ .../service/ParcelDispatchServiceTest.java | 133 ++++++++++++- 4 files changed, 322 insertions(+), 48 deletions(-) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java index 39a117b5..fa7d7804 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java @@ -8,6 +8,7 @@ import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; +import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -35,20 +36,25 @@ final class PublicParcelService implements ParcelService { @Override public CompletableFuture send(Player sender, Parcel parcel, List items) { CompletableFuture result = new CompletableFuture<>(); - this.scheduler.runAsync(() -> { - try { - this.dispatcher.dispatch(sender, parcel, items) - .whenComplete((success, throwable) -> { - if (throwable != null) { - result.completeExceptionally(throwable); - } else { - result.complete(success); - } - }); - } catch (Throwable throwable) { - result.completeExceptionally(throwable); - } - }); + try { + this.scheduler.runAsync(() -> { + try { + this.dispatcher.dispatch(sender, parcel, items) + .whenComplete((success, throwable) -> { + if (throwable != null) { + result.completeExceptionally(throwable); + } else { + result.complete(success); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally(throwable); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally( + new ParcelOperationException("Failed to submit parcel dispatch", throwable)); + } return result; } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 531feec2..4adf7bdf 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -8,12 +8,16 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; +import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.logging.Logger; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -95,46 +99,159 @@ private CompletableFuture dispatchInternal(Player sender, Parcel parcel // The parcel and its content were already persisted and the fee charged, // but the sender's staged storage could not be cleared. Fully roll back // (parcel + content + fee) instead of leaving orphaned content behind. - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return this.parcelService.rollbackSend(sender, parcel) - .thenApply(ignored -> false); + this.notifyCannotSend(sender); + return this.rollback(sender, parcel); } - CompletableFuture deliveryCreated; - try { - deliveryCreated = this.deliveryManager.create( - parcel.uuid(), - Instant.now().plus(delay) - ); - } catch (Throwable throwable) { - deliveryCreated = CompletableFuture.failedFuture(throwable); - } - - return deliveryCreated - .thenApply(delivery -> { - ParcelSendTask task = new ParcelSendTask( - parcel, - this.parcelService, - this.deliveryManager, - this.scheduler - ); - - this.scheduler.runLaterAsync(task, delay); - // Only confirm success here, once every step has succeeded, to avoid a - // "sent" notice immediately followed by "cannot send" on a rollback. - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.sent); - return true; - }) - .exceptionallyCompose(throwable -> - this.parcelService.rollbackSend(sender, parcel) - .thenApply(ignored -> false)); + return this.createDelivery(sender, parcel, items, delay); }); }); }) .exceptionally(throwable -> { + Throwable cause = unwrap(throwable); + if (cause instanceof CompensationException compensationException) { + throw compensationException; + } LOGGER.severe("Failed to dispatch parcel for player " + sender.getName() + ": " + throwable.getMessage()); - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + this.notifyCannotSend(sender); return false; }); } + + private CompletableFuture createDelivery( + Player sender, + Parcel parcel, + List items, + Duration delay + ) { + CompletableFuture deliveryCreated; + try { + deliveryCreated = this.deliveryManager.create(parcel.uuid(), Instant.now().plus(delay)); + } catch (Throwable throwable) { + deliveryCreated = CompletableFuture.failedFuture(throwable); + } + + return deliveryCreated.handle((delivery, throwable) -> { + if (throwable != null) { + return this.restoreAndRollback(sender, parcel, items); + } + return this.scheduleDelivery(sender, parcel, items, delay); + }).thenCompose(Function.identity()); + } + + private CompletableFuture scheduleDelivery( + Player sender, + Parcel parcel, + List items, + Duration delay + ) { + ParcelSendTask task = new ParcelSendTask( + parcel, + this.parcelService, + this.deliveryManager, + this.scheduler + ); + + try { + this.scheduler.runLaterAsync(task, delay); + } catch (Throwable throwable) { + return this.deleteDeliveryRestoreAndRollback(sender, parcel, items); + } + + try { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.sent); + } catch (Throwable throwable) { + LOGGER.warning("Parcel " + parcel.uuid() + + " was committed, but its success notice failed: " + throwable.getMessage()); + } + return CompletableFuture.completedFuture(true); + } + + private CompletableFuture deleteDeliveryRestoreAndRollback( + Player sender, + Parcel parcel, + List items + ) { + return this.compensationStep( + () -> this.deliveryManager.delete(parcel.uuid()), + "Failed to delete delivery " + parcel.uuid() + " during dispatch compensation" + ) + .thenCompose(deleted -> Boolean.TRUE.equals(deleted) + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new CompensationException( + "Delivery " + parcel.uuid() + " was not deleted during dispatch compensation" + ))) + .thenCompose(ignored -> this.restoreStorage(sender, items)) + .thenCompose(ignored -> this.rollbackParcel(sender, parcel)) + .thenApply(ignored -> false); + } + + private CompletableFuture restoreAndRollback( + Player sender, + Parcel parcel, + List items + ) { + return this.restoreStorage(sender, items) + .thenCompose(ignored -> this.rollbackParcel(sender, parcel)) + .thenApply(ignored -> false); + } + + private CompletableFuture rollback(Player sender, Parcel parcel) { + return this.rollbackParcel(sender, parcel).thenApply(ignored -> false); + } + + private CompletableFuture restoreStorage(Player sender, List items) { + return this.compensationStep( + () -> this.itemStorageManager.create(sender.getUniqueId(), items), + "Failed to restore item storage for player " + sender.getUniqueId() + ); + } + + private CompletableFuture rollbackParcel(Player sender, Parcel parcel) { + return this.compensationStep( + () -> this.parcelService.rollbackSend(sender, parcel), + "Failed to roll back parcel " + parcel.uuid() + ); + } + + private CompletableFuture compensationStep( + Supplier> action, + String failureMessage + ) { + try { + return action.get().exceptionallyCompose(throwable -> + CompletableFuture.failedFuture( + new CompensationException(failureMessage, unwrap(throwable)))); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture( + new CompensationException(failureMessage, unwrap(throwable))); + } + } + + private void notifyCannotSend(Player sender) { + try { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + } catch (Throwable throwable) { + LOGGER.warning("Failed to send dispatch failure notice to player " + + sender.getName() + ": " + throwable.getMessage()); + } + } + + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof CompletionException && throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable; + } + + private static final class CompensationException extends ParcelOperationException { + + private CompensationException(String message) { + super(message); + } + + private CompensationException(String message, Throwable cause) { + super(message, cause); + } + } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java index 3f36d421..2dde4693 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java @@ -1,7 +1,10 @@ package com.eternalcode.parcellockers; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -13,6 +16,7 @@ import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.Test; @@ -46,4 +50,22 @@ void sendDefersTheCompleteDispatchWorkflowOffTheCallingThread() { assertSame(Boolean.TRUE, result.join()); verify(delegate, never()).send(sender, parcel, items); } + + @Test + void schedulerSubmissionFailureCompletesFutureExceptionallyWithoutSynchronousThrow() { + PluginParcelService delegate = mock(PluginParcelService.class); + ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); + Scheduler scheduler = mock(Scheduler.class); + PublicParcelService service = new PublicParcelService(delegate, dispatcher, scheduler); + Player sender = mock(Player.class); + Parcel parcel = mock(Parcel.class); + List items = List.of(mock(ItemStack.class)); + when(scheduler.runAsync(any())) + .thenThrow(new IllegalStateException("submission failed")); + + CompletableFuture result = assertDoesNotThrow(() -> service.send(sender, parcel, items)); + + assertThrows(CompletionException.class, result::join); + verify(dispatcher, never()).dispatch(sender, parcel, items); + } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java index 38ae07b9..89ea982f 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -1,9 +1,12 @@ package com.eternalcode.parcellockers.parcel.service; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -13,17 +16,20 @@ import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; import com.eternalcode.parcellockers.delivery.Delivery; import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.itemstorage.ItemStorage; import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; 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.shared.exception.ParcelOperationException; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; @@ -122,6 +128,8 @@ void storageDeleteFailureRollsBackAndCompletesFalse() { @Test void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { + CompletableFuture restored = new CompletableFuture<>(); + CompletableFuture rolledBack = new CompletableFuture<>(); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) @@ -130,12 +138,25 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenThrow(new IllegalStateException("create failed")); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + .thenReturn(restored); when(this.parcelService.rollbackSend(this.sender, this.parcel)) - .thenReturn(CompletableFuture.completedFuture(null)); + .thenReturn(rolledBack); - assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); + + assertFalse(result.isDone()); + verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + + restored.complete(mock(ItemStorage.class)); + assertFalse(result.isDone()); verify(this.parcelService).rollbackSend(this.sender, this.parcel); + + rolledBack.complete(null); + + assertFalse(result.join()); + verify(this.itemStorageManager).create(this.parcel.sender(), this.items); verify(this.scheduler, never()).runLaterAsync(any(), any()); } @@ -149,15 +170,123 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + verify(this.itemStorageManager).create(this.parcel.sender(), this.items); verify(this.parcelService).rollbackSend(this.sender, this.parcel); verify(this.scheduler, never()).runLaterAsync(any(), any()); } + @Test + void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBack() { + CompletableFuture deliveryDeleted = new CompletableFuture<>(); + CompletableFuture restored = new CompletableFuture<>(); + CompletableFuture rolledBack = new CompletableFuture<>(); + this.stubSuccessfulStart(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.completedFuture(mock(Delivery.class))); + when(this.scheduler.runLaterAsync(any(), any())) + .thenThrow(new IllegalStateException("schedule failed")); + when(this.deliveryManager.delete(this.parcel.uuid())).thenReturn(deliveryDeleted); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)).thenReturn(restored); + when(this.parcelService.rollbackSend(this.sender, this.parcel)).thenReturn(rolledBack); + + CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); + + assertFalse(result.isDone()); + verify(this.deliveryManager).delete(this.parcel.uuid()); + verify(this.itemStorageManager, never()).create(this.parcel.sender(), this.items); + + deliveryDeleted.complete(true); + verify(this.itemStorageManager).create(this.parcel.sender(), this.items); + verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + + restored.complete(mock(ItemStorage.class)); + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + assertFalse(result.isDone()); + + rolledBack.complete(null); + assertFalse(result.join()); + } + + @Test + void restoreFailureCompletesExceptionallyInsteadOfFalse() { + this.stubSuccessfulStart(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("restore failed"))); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + } + + @Test + void deliveryDeleteFailureCompletesExceptionallyInsteadOfFalse() { + this.stubSuccessfulStart(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.completedFuture(mock(Delivery.class))); + when(this.scheduler.runLaterAsync(any(), any())) + .thenThrow(new IllegalStateException("schedule failed")); + when(this.deliveryManager.delete(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(false)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + verify(this.itemStorageManager, never()).create(this.parcel.sender(), this.items); + verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + } + + @Test + void rollbackFailureCompletesExceptionallyInsteadOfFalse() { + this.stubSuccessfulStart(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("rollback failed"))); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + } + + @Test + void successNoticeFailureDoesNotCompensateCommittedSend() { + this.stubSuccessfulStart(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.completedFuture(mock(Delivery.class))); + doThrow(new IllegalStateException("notice failed")) + .when(this.noticeService).player(eq(this.parcel.sender()), any()); + + assertTrue(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.deliveryManager, never()).delete(this.parcel.uuid()); + verify(this.itemStorageManager, never()).create(this.parcel.sender(), this.items); + verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + } + + private void stubSuccessfulStart() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.completedFuture(true)); + } + private static Parcel parcel() { return new Parcel( UUID.randomUUID(), From 1c0f319aa5f6996ab973963bb75752a0eae83f22 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:04:43 +0200 Subject: [PATCH 11/19] fix: reserve sender storage during dispatch --- .../itemstorage/ItemStorageManager.java | 154 +++++++++++++++++- .../itemstorage/ItemStorageReservation.java | 18 ++ .../parcel/service/ParcelDispatchService.java | 73 ++++++--- .../ItemStorageManagerReservationTest.java | 53 ++++++ .../service/ParcelDispatchServiceTest.java | 107 ++++++++++-- 5 files changed, 361 insertions(+), 44 deletions(-) create mode 100644 parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageReservation.java create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java index 358d51a2..e2047297 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -5,11 +5,14 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.bukkit.Server; import org.bukkit.command.CommandSender; import org.bukkit.inventory.ItemStack; @@ -20,6 +23,9 @@ public class ItemStorageManager { private final ItemStorageRepository itemStorageRepository; private final Server server; + private final Object reservationLock = new Object(); + private final Map reservations = new HashMap<>(); + private final Map ordinaryMutations = new HashMap<>(); public ItemStorageManager(ItemStorageRepository itemStorageRepository, Server server) { this.itemStorageRepository = itemStorageRepository; @@ -55,15 +61,38 @@ public CompletableFuture getOrCreate(UUID owner, List it } public CompletableFuture create(UUID owner, List items) { + if (!this.beginOrdinaryMutation(owner)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage is reserved for active dispatch: " + owner)); + } + + CompletableFuture operation; + try { + operation = this.createInternal(owner, items, true); + } catch (Throwable throwable) { + this.endOrdinaryMutation(owner); + return CompletableFuture.failedFuture(throwable); + } + return operation.whenComplete((ignored, throwable) -> this.endOrdinaryMutation(owner)); + } + + private CompletableFuture createInternal( + UUID owner, + List items, + boolean fireEvent + ) { ItemStorage oldItemStorage = this.cache.getIfPresent(owner); ItemStorage newItemStorage = new ItemStorage(owner, items); - // This is an update operation - fire ItemStorageUpdateEvent - ItemStorageUpdateEvent event = new ItemStorageUpdateEvent(oldItemStorage, newItemStorage); - this.server.getPluginManager().callEvent(event); + if (fireEvent) { + // This is an update operation - fire ItemStorageUpdateEvent + ItemStorageUpdateEvent event = new ItemStorageUpdateEvent(oldItemStorage, newItemStorage); + this.server.getPluginManager().callEvent(event); - if (event.isCancelled()) { - return CompletableFuture.failedFuture(new IllegalStateException("ItemStorage update was cancelled by event")); + if (event.isCancelled()) { + return CompletableFuture.failedFuture( + new IllegalStateException("ItemStorage update was cancelled by event")); + } } this.cache.put(owner, newItemStorage); @@ -91,12 +120,95 @@ private void cacheAll() { } public CompletableFuture delete(UUID owner) { + if (!this.beginOrdinaryMutation(owner)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage is reserved for active dispatch: " + owner)); + } + + CompletableFuture operation; + try { + operation = this.deleteInternal(owner); + } catch (Throwable throwable) { + this.endOrdinaryMutation(owner); + return CompletableFuture.failedFuture(throwable); + } + return operation.whenComplete((ignored, throwable) -> this.endOrdinaryMutation(owner)); + } + + public Optional reserve(UUID owner) { + synchronized (this.reservationLock) { + if (this.reservations.containsKey(owner) + || this.ordinaryMutations.getOrDefault(owner, 0) > 0) { + return Optional.empty(); + } + Reservation reservation = new Reservation(owner); + this.reservations.put(owner, reservation); + return Optional.of(reservation); + } + } + + private CompletableFuture deleteReserved(Reservation reservation) { + if (!this.isActive(reservation)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage reservation is no longer active: " + + reservation.owner())); + } + return this.deleteInternal(reservation.owner()); + } + + private CompletableFuture restoreReserved( + Reservation reservation, + List items + ) { + if (!this.isActive(reservation)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage reservation is no longer active: " + + reservation.owner())); + } + return this.createInternal(reservation.owner(), items, false); + } + + private CompletableFuture deleteInternal(UUID owner) { return this.itemStorageRepository.delete(owner).thenApply(i -> { this.cache.invalidate(owner); return i > 0; }); } + private boolean beginOrdinaryMutation(UUID owner) { + synchronized (this.reservationLock) { + if (this.reservations.containsKey(owner)) { + return false; + } + this.ordinaryMutations.merge(owner, 1, Integer::sum); + return true; + } + } + + private void endOrdinaryMutation(UUID owner) { + synchronized (this.reservationLock) { + int remaining = this.ordinaryMutations.getOrDefault(owner, 0) - 1; + if (remaining > 0) { + this.ordinaryMutations.put(owner, remaining); + } else { + this.ordinaryMutations.remove(owner); + } + } + } + + private boolean isActive(Reservation reservation) { + synchronized (this.reservationLock) { + return this.reservations.get(reservation.owner()) == reservation + && !reservation.closed.get(); + } + } + + private void release(Reservation reservation) { + synchronized (this.reservationLock) { + this.reservations.remove(reservation.owner(), reservation); + } + } + public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { return this.itemStorageRepository.deleteAll().thenAccept(deleted -> { noticeService.create() @@ -108,4 +220,36 @@ public CompletableFuture deleteAll(CommandSender sender, NoticeService not this.cache.invalidateAll(); }); } + + private final class Reservation implements ItemStorageReservation { + + private final UUID owner; + private final AtomicBoolean closed = new AtomicBoolean(); + + private Reservation(UUID owner) { + this.owner = owner; + } + + @Override + public UUID owner() { + return this.owner; + } + + @Override + public CompletableFuture delete() { + return ItemStorageManager.this.deleteReserved(this); + } + + @Override + public CompletableFuture restore(List items) { + return ItemStorageManager.this.restoreReserved(this, items); + } + + @Override + public void close() { + if (this.closed.compareAndSet(false, true)) { + ItemStorageManager.this.release(this); + } + } + } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageReservation.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageReservation.java new file mode 100644 index 00000000..9e0c8ad9 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageReservation.java @@ -0,0 +1,18 @@ +package com.eternalcode.parcellockers.itemstorage; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.inventory.ItemStack; + +public interface ItemStorageReservation extends AutoCloseable { + + UUID owner(); + + CompletableFuture delete(); + + CompletableFuture restore(List items); + + @Override + void close(); +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 4adf7bdf..3f6a7afe 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -4,6 +4,7 @@ import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; +import com.eternalcode.parcellockers.itemstorage.ItemStorageReservation; import com.eternalcode.parcellockers.locker.LockerManager; import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; @@ -57,21 +58,35 @@ public ParcelDispatchService( } public CompletableFuture dispatch(Player sender, Parcel parcel, List items) { + ItemStorageReservation reservation = this.itemStorageManager + .reserve(sender.getUniqueId()) + .orElse(null); + if (reservation == null) { + this.notifyCannotSend(sender); + return CompletableFuture.completedFuture(false); + } + UUID lockerId = parcel.destinationLocker(); CompletableFuture chained = this.lockerChains.compute(lockerId, (id, previous) -> { CompletableFuture predecessor = previous == null ? CompletableFuture.completedFuture(null) : previous.exceptionally(throwable -> null); - return predecessor.thenCompose(ignored -> this.dispatchInternal(sender, parcel, items)); + return predecessor.thenCompose(ignored -> + this.dispatchInternal(sender, parcel, items, reservation)); }); // Drop the chain entry once it drains so the map does not grow unbounded. chained.whenComplete((result, throwable) -> this.lockerChains.remove(lockerId, chained)); - return chained; + return chained.whenComplete((result, throwable) -> reservation.close()); } - private CompletableFuture dispatchInternal(Player sender, Parcel parcel, List items) { + private CompletableFuture dispatchInternal( + Player sender, + Parcel parcel, + List items, + ItemStorageReservation reservation + ) { return this.lockerManager.isLockerFull(parcel.destinationLocker()) .thenCompose(isFull -> { if (isFull) { @@ -90,7 +105,7 @@ private CompletableFuture dispatchInternal(Player sender, Parcel parcel return CompletableFuture.completedFuture(false); } - return this.itemStorageManager.delete(sender.getUniqueId()) + return reservation.delete() // A failed delete must trigger the rollback, not skip straight to the outer // exceptionally handler (which would leave the parcel sent and the fee charged). .exceptionally(throwable -> false) @@ -99,17 +114,20 @@ private CompletableFuture dispatchInternal(Player sender, Parcel parcel // The parcel and its content were already persisted and the fee charged, // but the sender's staged storage could not be cleared. Fully roll back // (parcel + content + fee) instead of leaving orphaned content behind. - this.notifyCannotSend(sender); return this.rollback(sender, parcel); } - return this.createDelivery(sender, parcel, items, delay); + return this.createDelivery(sender, parcel, items, delay, reservation); }); }); }) .exceptionally(throwable -> { Throwable cause = unwrap(throwable); if (cause instanceof CompensationException compensationException) { + LOGGER.severe("Failed to compensate parcel " + parcel.uuid() + + " for sender " + sender.getUniqueId() + ": " + + compensationException.getMessage()); + this.notifyCannotSend(sender); throw compensationException; } LOGGER.severe("Failed to dispatch parcel for player " + sender.getName() + ": " + throwable.getMessage()); @@ -122,7 +140,8 @@ private CompletableFuture createDelivery( Player sender, Parcel parcel, List items, - Duration delay + Duration delay, + ItemStorageReservation reservation ) { CompletableFuture deliveryCreated; try { @@ -133,9 +152,9 @@ private CompletableFuture createDelivery( return deliveryCreated.handle((delivery, throwable) -> { if (throwable != null) { - return this.restoreAndRollback(sender, parcel, items); + return this.restoreAndRollback(sender, parcel, items, reservation); } - return this.scheduleDelivery(sender, parcel, items, delay); + return this.scheduleDelivery(sender, parcel, items, delay, reservation); }).thenCompose(Function.identity()); } @@ -143,7 +162,8 @@ private CompletableFuture scheduleDelivery( Player sender, Parcel parcel, List items, - Duration delay + Duration delay, + ItemStorageReservation reservation ) { ParcelSendTask task = new ParcelSendTask( parcel, @@ -155,7 +175,7 @@ private CompletableFuture scheduleDelivery( try { this.scheduler.runLaterAsync(task, delay); } catch (Throwable throwable) { - return this.deleteDeliveryRestoreAndRollback(sender, parcel, items); + return this.deleteDeliveryRestoreAndRollback(sender, parcel, items, reservation); } try { @@ -170,7 +190,8 @@ private CompletableFuture scheduleDelivery( private CompletableFuture deleteDeliveryRestoreAndRollback( Player sender, Parcel parcel, - List items + List items, + ItemStorageReservation reservation ) { return this.compensationStep( () -> this.deliveryManager.delete(parcel.uuid()), @@ -181,32 +202,42 @@ private CompletableFuture deleteDeliveryRestoreAndRollback( : CompletableFuture.failedFuture(new CompensationException( "Delivery " + parcel.uuid() + " was not deleted during dispatch compensation" ))) - .thenCompose(ignored -> this.restoreStorage(sender, items)) + .thenCompose(ignored -> this.restoreStorage(reservation, items)) .thenCompose(ignored -> this.rollbackParcel(sender, parcel)) - .thenApply(ignored -> false); + .thenApply(ignored -> this.compensatedFailure(sender)); } private CompletableFuture restoreAndRollback( Player sender, Parcel parcel, - List items + List items, + ItemStorageReservation reservation ) { - return this.restoreStorage(sender, items) + return this.restoreStorage(reservation, items) .thenCompose(ignored -> this.rollbackParcel(sender, parcel)) - .thenApply(ignored -> false); + .thenApply(ignored -> this.compensatedFailure(sender)); } private CompletableFuture rollback(Player sender, Parcel parcel) { - return this.rollbackParcel(sender, parcel).thenApply(ignored -> false); + return this.rollbackParcel(sender, parcel) + .thenApply(ignored -> this.compensatedFailure(sender)); } - private CompletableFuture restoreStorage(Player sender, List items) { + private CompletableFuture restoreStorage( + ItemStorageReservation reservation, + List items + ) { return this.compensationStep( - () -> this.itemStorageManager.create(sender.getUniqueId(), items), - "Failed to restore item storage for player " + sender.getUniqueId() + () -> reservation.restore(items), + "Failed to restore item storage for player " + reservation.owner() ); } + private boolean compensatedFailure(Player sender) { + this.notifyCannotSend(sender); + return false; + } + private CompletableFuture rollbackParcel(Player sender, Parcel parcel) { return this.compensationStep( () -> this.parcelService.rollbackSend(sender, parcel), diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java new file mode 100644 index 00000000..4926371f --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java @@ -0,0 +1,53 @@ +package com.eternalcode.parcellockers.itemstorage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.parcellockers.itemstorage.repository.ItemStorageRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; + +class ItemStorageManagerReservationTest { + + @Test + void reservedRestoreCannotBeOverwrittenByFreshOrdinaryStorage() { + ItemStorageRepository repository = mock(ItemStorageRepository.class); + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + when(repository.fetchAll()).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + when(repository.delete(org.mockito.ArgumentMatchers.any())) + .thenReturn(CompletableFuture.completedFuture(1)); + when(repository.save(org.mockito.ArgumentMatchers.any())) + .thenReturn(CompletableFuture.completedFuture(null)); + ItemStorageManager manager = new ItemStorageManager(repository, server); + UUID owner = UUID.randomUUID(); + List original = List.of(mock(ItemStack.class)); + List fresh = List.of(mock(ItemStack.class)); + ItemStorageReservation reservation = manager.reserve(owner).orElseThrow(); + + reservation.delete().join(); + assertThrows(CompletionException.class, () -> manager.create(owner, fresh).join()); + assertThrows(CompletionException.class, () -> manager.delete(owner).join()); + reservation.restore(original).join(); + + assertEquals(original, manager.get(owner).join().orElseThrow().items()); + verify(repository).save(new ItemStorage(owner, original)); + + reservation.close(); + manager.create(owner, fresh).join(); + + assertEquals(fresh, manager.get(owner).join().orElseThrow().items()); + verify(repository, times(2)).save(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java index 89ea982f..fb084118 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -18,6 +19,7 @@ import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.itemstorage.ItemStorage; import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; +import com.eternalcode.parcellockers.itemstorage.ItemStorageReservation; import com.eternalcode.parcellockers.locker.LockerManager; import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; @@ -27,9 +29,14 @@ import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.ArrayList; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; @@ -41,6 +48,7 @@ class ParcelDispatchServiceTest { private final PluginParcelService parcelService = mock(PluginParcelService.class); private final DeliveryManager deliveryManager = mock(DeliveryManager.class); private final ItemStorageManager itemStorageManager = mock(ItemStorageManager.class); + private final ItemStorageReservation reservation = mock(ItemStorageReservation.class); private final Scheduler scheduler = mock(Scheduler.class); private final NoticeService noticeService = mock(NoticeService.class); private final PluginConfig config = new PluginConfig(); @@ -61,6 +69,8 @@ class ParcelDispatchServiceTest { void setUp() { this.config.settings.parcelSendDuration = Duration.ofMinutes(30); when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); + when(this.itemStorageManager.reserve(this.parcel.sender())) + .thenReturn(Optional.of(this.reservation)); } @Test @@ -70,7 +80,7 @@ void successCompletesOnlyAfterDeliveryIsPersistedAndTaskIsScheduled() { .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); - when(this.itemStorageManager.delete(this.parcel.sender())) + when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenReturn(deliveryCreated); @@ -84,6 +94,7 @@ void successCompletesOnlyAfterDeliveryIsPersistedAndTaskIsScheduled() { assertTrue(result.join()); verify(this.scheduler).runLaterAsync(any(), eq(this.config.settings.parcelSendDuration)); + verify(this.reservation).close(); } @Test @@ -94,6 +105,7 @@ void fullLockerCompletesFalseWithoutPersistingParcel() { assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); verify(this.parcelService, never()).send(any(), any(), any()); + verify(this.reservation).close(); } @Test @@ -114,7 +126,7 @@ void storageDeleteFailureRollsBackAndCompletesFalse() { .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); - when(this.itemStorageManager.delete(this.parcel.sender())) + when(this.reservation.delete()) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("delete failed"))); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); @@ -134,11 +146,11 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); - when(this.itemStorageManager.delete(this.parcel.sender())) + when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenThrow(new IllegalStateException("create failed")); - when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + when(this.reservation.restore(this.items)) .thenReturn(restored); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(rolledBack); @@ -156,7 +168,7 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { rolledBack.complete(null); assertFalse(result.join()); - verify(this.itemStorageManager).create(this.parcel.sender(), this.items); + verify(this.reservation).restore(this.items); verify(this.scheduler, never()).runLaterAsync(any(), any()); } @@ -166,20 +178,21 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); - when(this.itemStorageManager.delete(this.parcel.sender())) + when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); - when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); - verify(this.itemStorageManager).create(this.parcel.sender(), this.items); + verify(this.reservation).restore(this.items); verify(this.parcelService).rollbackSend(this.sender, this.parcel); verify(this.scheduler, never()).runLaterAsync(any(), any()); + verify(this.noticeService, times(1)).player(eq(this.parcel.sender()), any()); } @Test @@ -193,17 +206,17 @@ void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBack() { when(this.scheduler.runLaterAsync(any(), any())) .thenThrow(new IllegalStateException("schedule failed")); when(this.deliveryManager.delete(this.parcel.uuid())).thenReturn(deliveryDeleted); - when(this.itemStorageManager.create(this.parcel.sender(), this.items)).thenReturn(restored); + when(this.reservation.restore(this.items)).thenReturn(restored); when(this.parcelService.rollbackSend(this.sender, this.parcel)).thenReturn(rolledBack); CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); assertFalse(result.isDone()); verify(this.deliveryManager).delete(this.parcel.uuid()); - verify(this.itemStorageManager, never()).create(this.parcel.sender(), this.items); + verify(this.reservation, never()).restore(this.items); deliveryDeleted.complete(true); - verify(this.itemStorageManager).create(this.parcel.sender(), this.items); + verify(this.reservation).restore(this.items); verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); restored.complete(mock(ItemStorage.class)); @@ -216,17 +229,44 @@ void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBack() { @Test void restoreFailureCompletesExceptionallyInsteadOfFalse() { + List records = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + Logger logger = Logger.getLogger(ParcelDispatchService.class.getName()); + logger.addHandler(handler); this.stubSuccessfulStart(); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); - when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("restore failed"))); - CompletionException exception = assertThrows(CompletionException.class, - () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + CompletionException exception; + try { + exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + } finally { + logger.removeHandler(handler); + } assertInstanceOf(ParcelOperationException.class, exception.getCause()); verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + verify(this.noticeService, times(1)).player(eq(this.parcel.sender()), any()); + verify(this.reservation).close(); + assertTrue(records.stream().anyMatch(record -> + record.getMessage().contains(this.parcel.uuid().toString()) + && record.getMessage().contains(this.parcel.sender().toString()))); } @Test @@ -243,7 +283,7 @@ void deliveryDeleteFailureCompletesExceptionallyInsteadOfFalse() { () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); assertInstanceOf(ParcelOperationException.class, exception.getCause()); - verify(this.itemStorageManager, never()).create(this.parcel.sender(), this.items); + verify(this.reservation, never()).restore(this.items); verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); } @@ -252,7 +292,7 @@ void rollbackFailureCompletesExceptionallyInsteadOfFalse() { this.stubSuccessfulStart(); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); - when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("rollback failed"))); @@ -274,7 +314,7 @@ void successNoticeFailureDoesNotCompensateCommittedSend() { assertTrue(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); verify(this.deliveryManager, never()).delete(this.parcel.uuid()); - verify(this.itemStorageManager, never()).create(this.parcel.sender(), this.items); + verify(this.reservation, never()).restore(this.items); verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); } @@ -283,10 +323,41 @@ private void stubSuccessfulStart() { .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); - when(this.itemStorageManager.delete(this.parcel.sender())) + when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); } + @Test + void secondDispatchBySameSenderToDifferentLockerIsRejectedWhileFirstIsActive() { + Parcel secondParcel = new Parcel( + UUID.randomUUID(), + this.parcel.sender(), + "second", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.SENT + ); + CompletableFuture firstLockerFull = new CompletableFuture<>(); + when(this.itemStorageManager.reserve(this.parcel.sender())) + .thenReturn(Optional.of(this.reservation), Optional.empty()); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(firstLockerFull); + + CompletableFuture first = this.dispatcher.dispatch(this.sender, this.parcel, this.items); + CompletableFuture second = this.dispatcher.dispatch(this.sender, secondParcel, this.items); + + assertFalse(second.join()); + verify(this.lockerManager, never()).isLockerFull(secondParcel.destinationLocker()); + + firstLockerFull.complete(true); + assertFalse(first.join()); + verify(this.reservation).close(); + } + private static Parcel parcel() { return new Parcel( UUID.randomUUID(), From 11457bdf6b932e3394d6f8ea69170881e5151bca Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:40:39 +0200 Subject: [PATCH 12/19] fix: enforce item storage consistency protocol --- .../itemstorage/ItemStorageManager.java | 220 ++++++++++------ .../parcel/service/ParcelDispatchService.java | 42 +-- .../ItemStorageManagerConsistencyTest.java | 245 ++++++++++++++++++ .../ItemStorageManagerReservationTest.java | 3 +- .../service/ParcelDispatchServiceTest.java | 20 +- 5 files changed, 438 insertions(+), 92 deletions(-) create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java index e2047297..7fac1d89 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; import org.bukkit.Server; import org.bukkit.command.CommandSender; import org.bukkit.inventory.ItemStack; @@ -25,7 +26,8 @@ public class ItemStorageManager { private final Server server; private final Object reservationLock = new Object(); private final Map reservations = new HashMap<>(); - private final Map ordinaryMutations = new HashMap<>(); + private final Map ordinaryOperations = new HashMap<>(); + private boolean globalOperation; public ItemStorageManager(ItemStorageRepository itemStorageRepository, Server server) { this.itemStorageRepository = itemStorageRepository; @@ -40,40 +42,37 @@ public ItemStorageManager(ItemStorageRepository itemStorageRepository, Server se } public CompletableFuture> get(UUID parcelId) { - ItemStorage content = this.cache.getIfPresent(parcelId); - if (content != null) { - return CompletableFuture.completedFuture(Optional.of(content)); - } - return this.itemStorageRepository.fetch(parcelId).thenApply(optional -> { - optional.ifPresent(value -> this.cache.put(parcelId, value)); - return optional; + return this.runOrdinaryOperation(parcelId, () -> { + ItemStorage content = this.cache.getIfPresent(parcelId); + if (content != null) { + return CompletableFuture.completedFuture(Optional.of(content)); + } + return this.itemStorageRepository.fetch(parcelId).thenApply(optional -> { + optional.ifPresent(value -> this.cache.put(parcelId, value)); + return optional; + }); }); } public CompletableFuture getOrCreate(UUID owner, List items) { - ItemStorage existing = this.cache.getIfPresent(owner); - if (existing != null) { - return CompletableFuture.completedFuture(existing); - } - // Do not call create() from inside cache.get(owner, loader): create() writes the same key - // back into the cache, and Caffeine forbids mutating the key being computed. - return this.create(owner, items); + return this.runOrdinaryOperation(owner, () -> { + ItemStorage existing = this.cache.getIfPresent(owner); + if (existing != null) { + return CompletableFuture.completedFuture(existing); + } + return this.itemStorageRepository.fetch(owner).thenCompose(persisted -> { + if (persisted.isPresent()) { + ItemStorage itemStorage = persisted.get(); + this.cache.put(owner, itemStorage); + return CompletableFuture.completedFuture(itemStorage); + } + return this.createInternal(owner, items, true); + }); + }); } public CompletableFuture create(UUID owner, List items) { - if (!this.beginOrdinaryMutation(owner)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Item storage is reserved for active dispatch: " + owner)); - } - - CompletableFuture operation; - try { - operation = this.createInternal(owner, items, true); - } catch (Throwable throwable) { - this.endOrdinaryMutation(owner); - return CompletableFuture.failedFuture(throwable); - } - return operation.whenComplete((ignored, throwable) -> this.endOrdinaryMutation(owner)); + return this.runOrdinaryOperation(owner, () -> this.createInternal(owner, items, true)); } private CompletableFuture createInternal( @@ -113,32 +112,21 @@ private CompletableFuture createInternal( } private void cacheAll() { - this.itemStorageRepository.fetchAll() + this.runGlobalOperation(() -> this.itemStorageRepository.fetchAll() .thenAccept(all -> all.ifPresent(list -> list.forEach(itemStorage -> this.cache.put( itemStorage.owner(), - itemStorage)))); + itemStorage))))); } public CompletableFuture delete(UUID owner) { - if (!this.beginOrdinaryMutation(owner)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Item storage is reserved for active dispatch: " + owner)); - } - - CompletableFuture operation; - try { - operation = this.deleteInternal(owner); - } catch (Throwable throwable) { - this.endOrdinaryMutation(owner); - return CompletableFuture.failedFuture(throwable); - } - return operation.whenComplete((ignored, throwable) -> this.endOrdinaryMutation(owner)); + return this.runOrdinaryOperation(owner, () -> this.deleteInternal(owner)); } public Optional reserve(UUID owner) { synchronized (this.reservationLock) { - if (this.reservations.containsKey(owner) - || this.ordinaryMutations.getOrDefault(owner, 0) > 0) { + if (this.globalOperation + || this.reservations.containsKey(owner) + || this.ordinaryOperations.getOrDefault(owner, 0) > 0) { return Optional.empty(); } Reservation reservation = new Reservation(owner); @@ -148,24 +136,18 @@ public Optional reserve(UUID owner) { } private CompletableFuture deleteReserved(Reservation reservation) { - if (!this.isActive(reservation)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Item storage reservation is no longer active: " - + reservation.owner())); - } - return this.deleteInternal(reservation.owner()); + return this.runReservedOperation( + reservation, + () -> this.deleteInternal(reservation.owner())); } private CompletableFuture restoreReserved( Reservation reservation, List items ) { - if (!this.isActive(reservation)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Item storage reservation is no longer active: " - + reservation.owner())); - } - return this.createInternal(reservation.owner(), items, false); + return this.runReservedOperation( + reservation, + () -> this.createInternal(reservation.owner(), items, false)); } private CompletableFuture deleteInternal(UUID owner) { @@ -175,56 +157,146 @@ private CompletableFuture deleteInternal(UUID owner) { }); } - private boolean beginOrdinaryMutation(UUID owner) { + private CompletableFuture runOrdinaryOperation( + UUID owner, + Supplier> action + ) { + if (!this.beginOrdinaryOperation(owner)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage is unavailable for ordinary operation: " + + owner)); + } + + try { + return action.get() + .whenComplete((ignored, throwable) -> this.endOrdinaryOperation(owner)); + } catch (Throwable throwable) { + this.endOrdinaryOperation(owner); + return CompletableFuture.failedFuture(throwable); + } + } + + private boolean beginOrdinaryOperation(UUID owner) { synchronized (this.reservationLock) { - if (this.reservations.containsKey(owner)) { + if (this.globalOperation || this.reservations.containsKey(owner)) { return false; } - this.ordinaryMutations.merge(owner, 1, Integer::sum); + this.ordinaryOperations.merge(owner, 1, Integer::sum); return true; } } - private void endOrdinaryMutation(UUID owner) { + private void endOrdinaryOperation(UUID owner) { synchronized (this.reservationLock) { - int remaining = this.ordinaryMutations.getOrDefault(owner, 0) - 1; + int remaining = this.ordinaryOperations.getOrDefault(owner, 0) - 1; if (remaining > 0) { - this.ordinaryMutations.put(owner, remaining); + this.ordinaryOperations.put(owner, remaining); } else { - this.ordinaryMutations.remove(owner); + this.ordinaryOperations.remove(owner); } } } - private boolean isActive(Reservation reservation) { + private CompletableFuture runReservedOperation( + Reservation reservation, + Supplier> action + ) { + if (!this.beginReservedOperation(reservation)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage reservation is no longer active: " + + reservation.owner())); + } + + try { + return action.get().whenComplete( + (ignored, throwable) -> this.endReservedOperation(reservation)); + } catch (Throwable throwable) { + this.endReservedOperation(reservation); + return CompletableFuture.failedFuture(throwable); + } + } + + private boolean beginReservedOperation(Reservation reservation) { + synchronized (this.reservationLock) { + if (this.reservations.get(reservation.owner()) != reservation + || reservation.closed.get()) { + return false; + } + reservation.activeOperations++; + return true; + } + } + + private void endReservedOperation(Reservation reservation) { synchronized (this.reservationLock) { - return this.reservations.get(reservation.owner()) == reservation - && !reservation.closed.get(); + reservation.activeOperations--; + if (reservation.activeOperations == 0 && reservation.closed.get()) { + this.reservations.remove(reservation.owner(), reservation); + } } } - private void release(Reservation reservation) { + private void requestRelease(Reservation reservation) { synchronized (this.reservationLock) { - this.reservations.remove(reservation.owner(), reservation); + if (reservation.activeOperations == 0) { + this.reservations.remove(reservation.owner(), reservation); + } + } + } + + private CompletableFuture runGlobalOperation( + Supplier> action + ) { + if (!this.beginGlobalOperation()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Item storage has active owner operations")); + } + + try { + return action.get() + .whenComplete((ignored, throwable) -> this.endGlobalOperation()); + } catch (Throwable throwable) { + this.endGlobalOperation(); + return CompletableFuture.failedFuture(throwable); + } + } + + private boolean beginGlobalOperation() { + synchronized (this.reservationLock) { + if (this.globalOperation + || !this.reservations.isEmpty() + || !this.ordinaryOperations.isEmpty()) { + return false; + } + this.globalOperation = true; + return true; + } + } + + private void endGlobalOperation() { + synchronized (this.reservationLock) { + this.globalOperation = false; } } public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { - return this.itemStorageRepository.deleteAll().thenAccept(deleted -> { + return this.runGlobalOperation(() -> this.itemStorageRepository.deleteAll() + .thenAccept(deleted -> { + this.cache.invalidateAll(); + noticeService.create() .viewer(sender) .notice(messages -> messages.admin.deletedItemStorages) .placeholder("{COUNT}", deleted.toString()) .send(); - - this.cache.invalidateAll(); - }); + })); } private final class Reservation implements ItemStorageReservation { private final UUID owner; private final AtomicBoolean closed = new AtomicBoolean(); + private int activeOperations; private Reservation(UUID owner) { this.owner = owner; @@ -248,7 +320,7 @@ public CompletableFuture restore(List items) { @Override public void close() { if (this.closed.compareAndSet(false, true)) { - ItemStorageManager.this.release(this); + ItemStorageManager.this.requestRelease(this); } } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 3f6a7afe..ad8d0265 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -19,6 +19,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Supplier; +import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -66,19 +67,26 @@ public CompletableFuture dispatch(Player sender, Parcel parcel, List chained = this.lockerChains.compute(lockerId, (id, previous) -> { - CompletableFuture predecessor = previous == null - ? CompletableFuture.completedFuture(null) - : previous.exceptionally(throwable -> null); - return predecessor.thenCompose(ignored -> - this.dispatchInternal(sender, parcel, items, reservation)); - }); - - // Drop the chain entry once it drains so the map does not grow unbounded. - chained.whenComplete((result, throwable) -> this.lockerChains.remove(lockerId, chained)); - return chained.whenComplete((result, throwable) -> reservation.close()); + try { + UUID lockerId = parcel.destinationLocker(); + + CompletableFuture chained = + this.lockerChains.compute(lockerId, (id, previous) -> { + CompletableFuture predecessor = previous == null + ? CompletableFuture.completedFuture(null) + : previous.exceptionally(throwable -> null); + return predecessor.thenCompose(ignored -> + this.dispatchInternal(sender, parcel, items, reservation)); + }); + + CompletableFuture draining = chained.whenComplete( + (result, throwable) -> this.lockerChains.remove(lockerId, chained)); + return draining.whenComplete((result, throwable) -> reservation.close()); + } catch (Throwable throwable) { + reservation.close(); + this.notifyCannotSend(sender); + return CompletableFuture.failedFuture(throwable); + } } private CompletableFuture dispatchInternal( @@ -124,9 +132,11 @@ private CompletableFuture dispatchInternal( .exceptionally(throwable -> { Throwable cause = unwrap(throwable); if (cause instanceof CompensationException compensationException) { - LOGGER.severe("Failed to compensate parcel " + parcel.uuid() - + " for sender " + sender.getUniqueId() + ": " - + compensationException.getMessage()); + LOGGER.log( + Level.SEVERE, + "Failed to compensate parcel " + parcel.uuid() + + " for sender " + sender.getUniqueId(), + compensationException); this.notifyCannotSend(sender); throw compensationException; } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java new file mode 100644 index 00000000..6824a5db --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java @@ -0,0 +1,245 @@ +package com.eternalcode.parcellockers.itemstorage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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.parcellockers.itemstorage.repository.ItemStorageRepository; +import com.eternalcode.multification.notice.NoticeBroadcast; +import com.eternalcode.parcellockers.notification.NoticeService; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.bukkit.Server; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ItemStorageManagerConsistencyTest { + + private final ItemStorageRepository repository = mock(ItemStorageRepository.class); + private final Server server = mock(Server.class); + private final NoticeService noticeService = mock(NoticeService.class); + private final CommandSender commandSender = mock(CommandSender.class); + + @BeforeEach + void setUp() { + when(this.server.getPluginManager()).thenReturn(mock(PluginManager.class)); + when(this.repository.fetchAll()) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + NoticeBroadcast broadcast = mock(NoticeBroadcast.class, RETURNS_SELF); + when(this.noticeService.create()).thenReturn(broadcast); + } + + @Test + void readStartedBeforeReservationMustCompleteBeforeReservationCanBeAcquired() { + UUID owner = UUID.randomUUID(); + CompletableFuture> fetched = new CompletableFuture<>(); + when(this.repository.fetch(owner)).thenReturn(fetched); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture> read = manager.get(owner); + + assertTrue(manager.reserve(owner).isEmpty()); + fetched.complete(Optional.empty()); + assertTrue(read.join().isEmpty()); + assertTrue(manager.reserve(owner).isPresent()); + } + + @Test + void getOrCreateMissMustCompleteBeforeReservationCanBeAcquired() { + UUID owner = UUID.randomUUID(); + CompletableFuture saved = new CompletableFuture<>(); + when(this.repository.fetch(owner)) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + when(this.repository.save(any())).thenReturn(saved); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture operation = + manager.getOrCreate(owner, List.of(mock(ItemStack.class))); + + assertTrue(manager.reserve(owner).isEmpty()); + saved.complete(null); + operation.join(); + assertTrue(manager.reserve(owner).isPresent()); + } + + @Test + void getOrCreateCacheMissReturnsPersistedStorageWithoutOverwritingIt() { + UUID owner = UUID.randomUUID(); + ItemStorage persisted = new ItemStorage(owner, List.of(mock(ItemStack.class))); + when(this.repository.fetch(owner)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(persisted))); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + ItemStorage result = manager.getOrCreate(owner, List.of(mock(ItemStack.class))).join(); + + assertEquals(persisted, result); + verify(this.repository, never()).save(any()); + } + + @Test + void cacheHitIsRejectedWhileOwnerIsReserved() { + UUID owner = UUID.randomUUID(); + ItemStorage storage = new ItemStorage(owner, List.of(mock(ItemStack.class))); + when(this.repository.fetch(owner)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(storage))); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + assertEquals(storage, manager.get(owner).join().orElseThrow()); + ItemStorageReservation reservation = manager.reserve(owner).orElseThrow(); + + assertThrows(CompletionException.class, () -> manager.get(owner).join()); + + reservation.close(); + } + + @Test + void pendingRepositoryFetchMustDrainBeforeReservedDeleteCanPreventStaleCacheResurrection() { + UUID owner = UUID.randomUUID(); + ItemStorage stale = new ItemStorage(owner, List.of(mock(ItemStack.class))); + CompletableFuture> fetched = new CompletableFuture<>(); + when(this.repository.fetch(owner)) + .thenReturn(fetched, CompletableFuture.completedFuture(Optional.empty())); + when(this.repository.delete(owner)).thenReturn(CompletableFuture.completedFuture(1)); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture> read = manager.get(owner); + assertTrue(manager.reserve(owner).isEmpty()); + + fetched.complete(Optional.of(stale)); + assertEquals(stale, read.join().orElseThrow()); + ItemStorageReservation reservation = manager.reserve(owner).orElseThrow(); + assertTrue(reservation.delete().join()); + reservation.close(); + + assertTrue(manager.get(owner).join().isEmpty()); + } + + @Test + void startupCacheWarmupAndReservationCannotOverlap() { + UUID owner = UUID.randomUUID(); + CompletableFuture>> fetchedAll = new CompletableFuture<>(); + when(this.repository.fetchAll()).thenReturn(fetchedAll); + + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + assertTrue(manager.reserve(owner).isEmpty()); + fetchedAll.complete(Optional.empty()); + assertTrue(manager.reserve(owner).isPresent()); + } + + @Test + void closingReservationDoesNotReleaseOwnershipBeforeRestoreCompletes() { + UUID owner = UUID.randomUUID(); + CompletableFuture saved = new CompletableFuture<>(); + when(this.repository.save(any())).thenReturn(saved); + when(this.repository.deleteAll()).thenReturn(CompletableFuture.completedFuture(0)); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + ItemStorageReservation reservation = manager.reserve(owner).orElseThrow(); + + CompletableFuture restore = + reservation.restore(List.of(mock(ItemStack.class))); + reservation.close(); + + assertThrows(CompletionException.class, + () -> manager.deleteAll(this.commandSender, this.noticeService).join()); + verify(this.repository, never()).deleteAll(); + + saved.complete(null); + restore.join(); + manager.deleteAll(this.commandSender, this.noticeService).join(); + verify(this.repository).deleteAll(); + } + + @Test + void deleteAllReleasesGlobalOwnershipAfterSuccess() { + UUID owner = UUID.randomUUID(); + when(this.repository.deleteAll()).thenReturn(CompletableFuture.completedFuture(0)); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + manager.deleteAll(this.commandSender, this.noticeService).join(); + + assertTrue(manager.reserve(owner).isPresent()); + } + + @Test + void deleteAllReleasesGlobalOwnershipAfterFailure() { + UUID owner = UUID.randomUUID(); + CompletableFuture deleted = new CompletableFuture<>(); + when(this.repository.deleteAll()).thenReturn(deleted); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture deleteAll = + manager.deleteAll(this.commandSender, this.noticeService); + assertTrue(manager.reserve(owner).isEmpty()); + + deleted.completeExceptionally(new IllegalStateException("delete all failed")); + assertThrows(CompletionException.class, deleteAll::join); + assertTrue(manager.reserve(owner).isPresent()); + } + + @Test + void deleteAllReleasesGlobalOwnershipAfterSynchronousRepositoryFailure() { + UUID owner = UUID.randomUUID(); + when(this.repository.deleteAll()) + .thenThrow(new IllegalStateException("synchronous delete all failure")); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + assertThrows(CompletionException.class, + () -> manager.deleteAll(this.commandSender, this.noticeService).join()); + + assertTrue(manager.reserve(owner).isPresent()); + } + + @Test + void deleteAllCannotStartWhileReadIsActive() { + UUID owner = UUID.randomUUID(); + CompletableFuture> fetched = new CompletableFuture<>(); + when(this.repository.fetch(owner)).thenReturn(fetched); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture> read = manager.get(owner); + + assertThrows(CompletionException.class, + () -> manager.deleteAll(this.commandSender, this.noticeService).join()); + verify(this.repository, never()).deleteAll(); + fetched.complete(Optional.empty()); + read.join(); + } + + @Test + void ordinaryOperationsCannotStartDuringGlobalOperation() { + UUID owner = UUID.randomUUID(); + CompletableFuture deleted = new CompletableFuture<>(); + when(this.repository.deleteAll()).thenReturn(deleted); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture deleteAll = + manager.deleteAll(this.commandSender, this.noticeService); + + assertThrows(CompletionException.class, + () -> manager.create(owner, List.of()).join()); + assertThrows(CompletionException.class, () -> manager.delete(owner).join()); + assertThrows(CompletionException.class, () -> manager.get(owner).join()); + assertThrows(CompletionException.class, + () -> manager.getOrCreate(owner, List.of()).join()); + verify(this.repository, never()).save(any()); + verify(this.repository, never()).delete(owner); + verify(this.repository, never()).fetch(owner); + + deleted.complete(0); + assertFalse(deleteAll.isCompletedExceptionally()); + deleteAll.join(); + } +} diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java index 4926371f..00c87110 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java @@ -41,10 +41,11 @@ void reservedRestoreCannotBeOverwrittenByFreshOrdinaryStorage() { assertThrows(CompletionException.class, () -> manager.delete(owner).join()); reservation.restore(original).join(); - assertEquals(original, manager.get(owner).join().orElseThrow().items()); + assertThrows(CompletionException.class, () -> manager.get(owner).join()); verify(repository).save(new ItemStorage(owner, original)); reservation.close(); + assertEquals(original, manager.get(owner).join().orElseThrow().items()); manager.create(owner, fresh).join(); assertEquals(fresh, manager.get(owner).join().orElseThrow().items()); diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java index fb084118..b67f95b7 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -1,6 +1,7 @@ package com.eternalcode.parcellockers.parcel.service; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -266,7 +267,8 @@ public void close() { verify(this.reservation).close(); assertTrue(records.stream().anyMatch(record -> record.getMessage().contains(this.parcel.uuid().toString()) - && record.getMessage().contains(this.parcel.sender().toString()))); + && record.getMessage().contains(this.parcel.sender().toString()) + && record.getThrown() == exception.getCause())); } @Test @@ -358,6 +360,22 @@ void secondDispatchBySameSenderToDifferentLockerIsRejectedWhileFirstIsActive() { verify(this.reservation).close(); } + @Test + void synchronousFailureBeforeLockerChainRegistrationReleasesReservation() { + Parcel brokenParcel = mock(Parcel.class); + IllegalStateException failure = new IllegalStateException("destination unavailable"); + when(brokenParcel.destinationLocker()).thenThrow(failure); + + CompletableFuture result = + this.dispatcher.dispatch(this.sender, brokenParcel, this.items); + + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertEquals(failure, exception.getCause()); + verify(this.reservation).close(); + verify(this.lockerManager, never()).isLockerFull(any()); + } + private static Parcel parcel() { return new Parcel( UUID.randomUUID(), From 5d6ddc96c84243106e4eb092e27d03a439bbd9eb Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:04:52 +0200 Subject: [PATCH 13/19] fix: serialize item storage owner operations --- .../itemstorage/ItemStorageManager.java | 9 ++- .../ItemStorageManagerConsistencyTest.java | 61 +++++++++++++++++++ .../ItemStorageManagerReservationTest.java | 28 +++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java index 7fac1d89..2c38f4c9 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -178,10 +178,12 @@ private CompletableFuture runOrdinaryOperation( private boolean beginOrdinaryOperation(UUID owner) { synchronized (this.reservationLock) { - if (this.globalOperation || this.reservations.containsKey(owner)) { + if (this.globalOperation + || this.reservations.containsKey(owner) + || this.ordinaryOperations.containsKey(owner)) { return false; } - this.ordinaryOperations.merge(owner, 1, Integer::sum); + this.ordinaryOperations.put(owner, 1); return true; } } @@ -219,7 +221,8 @@ private CompletableFuture runReservedOperation( private boolean beginReservedOperation(Reservation reservation) { synchronized (this.reservationLock) { if (this.reservations.get(reservation.owner()) != reservation - || reservation.closed.get()) { + || reservation.closed.get() + || reservation.activeOperations > 0) { return false; } reservation.activeOperations++; diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java index 6824a5db..d9e3244f 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java @@ -57,6 +57,67 @@ void readStartedBeforeReservationMustCompleteBeforeReservationCanBeAcquired() { assertTrue(manager.reserve(owner).isPresent()); } + @Test + void cacheMissGetRejectsSameOwnerDeleteUntilReadCompletes() { + UUID owner = UUID.randomUUID(); + ItemStorage stale = new ItemStorage(owner, List.of(mock(ItemStack.class))); + CompletableFuture> fetched = new CompletableFuture<>(); + when(this.repository.fetch(owner)).thenReturn(fetched); + when(this.repository.delete(owner)).thenReturn(CompletableFuture.completedFuture(1)); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture> read = manager.get(owner); + + assertThrows(CompletionException.class, () -> manager.delete(owner).join()); + verify(this.repository, never()).delete(owner); + + fetched.complete(Optional.of(stale)); + assertEquals(stale, read.join().orElseThrow()); + assertTrue(manager.delete(owner).join()); + verify(this.repository).delete(owner); + } + + @Test + void overlappingSameOwnerCreatesAreRejectedBeforeSecondWrite() { + UUID owner = UUID.randomUUID(); + List firstItems = List.of(mock(ItemStack.class)); + List secondItems = List.of(mock(ItemStack.class)); + CompletableFuture firstSave = new CompletableFuture<>(); + when(this.repository.save(any())) + .thenReturn(firstSave, CompletableFuture.completedFuture(null)); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture firstCreate = manager.create(owner, firstItems); + + assertThrows(CompletionException.class, + () -> manager.create(owner, secondItems).join()); + verify(this.repository).save(new ItemStorage(owner, firstItems)); + + firstSave.complete(null); + assertEquals(firstItems, firstCreate.join().items()); + assertEquals(firstItems, manager.get(owner).join().orElseThrow().items()); + } + + @Test + void differentOwnerCreatesMayOverlap() { + UUID firstOwner = UUID.randomUUID(); + UUID secondOwner = UUID.randomUUID(); + CompletableFuture firstSave = new CompletableFuture<>(); + CompletableFuture secondSave = new CompletableFuture<>(); + when(this.repository.save(any())).thenReturn(firstSave, secondSave); + ItemStorageManager manager = new ItemStorageManager(this.repository, this.server); + + CompletableFuture firstCreate = manager.create(firstOwner, List.of()); + CompletableFuture secondCreate = manager.create(secondOwner, List.of()); + + verify(this.repository).save(new ItemStorage(firstOwner, List.of())); + verify(this.repository).save(new ItemStorage(secondOwner, List.of())); + firstSave.complete(null); + secondSave.complete(null); + assertEquals(firstOwner, firstCreate.join().owner()); + assertEquals(secondOwner, secondCreate.join().owner()); + } + @Test void getOrCreateMissMustCompleteBeforeReservationCanBeAcquired() { UUID owner = UUID.randomUUID(); diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java index 00c87110..9adfce93 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java @@ -2,6 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -20,6 +23,31 @@ class ItemStorageManagerReservationTest { + @Test + void overlappingReservationOperationsAreRejectedUntilActiveOperationCompletes() { + ItemStorageRepository repository = mock(ItemStorageRepository.class); + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + when(repository.fetchAll()).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + CompletableFuture deleted = new CompletableFuture<>(); + when(repository.delete(any())).thenReturn(deleted); + when(repository.save(any())).thenReturn(CompletableFuture.completedFuture(null)); + ItemStorageManager manager = new ItemStorageManager(repository, server); + UUID owner = UUID.randomUUID(); + ItemStorageReservation reservation = manager.reserve(owner).orElseThrow(); + + CompletableFuture firstDelete = reservation.delete(); + + assertThrows(CompletionException.class, + () -> reservation.restore(List.of(mock(ItemStack.class))).join()); + verify(repository, never()).save(any()); + + deleted.complete(1); + assertTrue(firstDelete.join()); + reservation.restore(List.of()).join(); + verify(repository).save(new ItemStorage(owner, List.of())); + } + @Test void reservedRestoreCannotBeOverwrittenByFreshOrdinaryStorage() { ItemStorageRepository repository = mock(ItemStorageRepository.class); From 110bd8ecd651dbb93ca919cf80d37be552824b91 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:08:02 +0200 Subject: [PATCH 14/19] style: remove trailing whitespace --- .../parcellockers/itemstorage/ItemStorageManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java index 2c38f4c9..33b684b6 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -117,7 +117,7 @@ private void cacheAll() { itemStorage.owner(), itemStorage))))); } - + public CompletableFuture delete(UUID owner) { return this.runOrdinaryOperation(owner, () -> this.deleteInternal(owner)); } From d27dbf4239dcfcf95e2793bfa6fb93eab88f07d1 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:16:57 +0200 Subject: [PATCH 15/19] fix: enforce public parcel operation contracts --- .../parcellockers/PublicParcelService.java | 140 +++++++-- .../parcel/service/ParcelDispatchService.java | 223 ++++++++------ .../parcel/service/ParcelServiceImpl.java | 278 ++++++++++++++---- .../PublicParcelServiceTest.java | 211 +++++++++++-- .../service/ParcelDispatchServiceTest.java | 143 +++++++-- .../parcel/service/ParcelServiceImplTest.java | 253 ++++++++++++++++ 6 files changed, 1039 insertions(+), 209 deletions(-) create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java index fa7d7804..aba40188 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java @@ -9,10 +9,13 @@ import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import com.eternalcode.parcellockers.shared.exception.ValidationException; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Supplier; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.Nullable; @@ -35,27 +38,21 @@ final class PublicParcelService implements ParcelService { @Override public CompletableFuture send(Player sender, Parcel parcel, List items) { - CompletableFuture result = new CompletableFuture<>(); try { - this.scheduler.runAsync(() -> { - try { - this.dispatcher.dispatch(sender, parcel, items) - .whenComplete((success, throwable) -> { - if (throwable != null) { - result.completeExceptionally(throwable); - } else { - result.complete(success); - } - }); - } catch (Throwable throwable) { - result.completeExceptionally(throwable); - } - }); + validateSend(sender, parcel, items); + List snapshot = items.stream() + .map(ItemStack::clone) + .toList(); + return this.submitAsync( + () -> this.dispatcher.dispatch(sender, parcel, snapshot), + "Failed to submit parcel dispatch" + ); + } catch (ValidationException exception) { + return CompletableFuture.failedFuture(exception); } catch (Throwable throwable) { - result.completeExceptionally( - new ParcelOperationException("Failed to submit parcel dispatch", throwable)); + return CompletableFuture.failedFuture( + operationFailure("Failed to snapshot parcel contents", throwable)); } - return result; } @Override @@ -70,7 +67,18 @@ public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus ex @Override public CompletableFuture collect(Player player, Parcel parcel) { - return this.delegate.collect(player, parcel); + try { + validateCollect(player, parcel); + return this.submitAsync( + () -> this.delegate.collect(player, parcel), + "Failed to submit parcel collection" + ); + } catch (ValidationException exception) { + return CompletableFuture.failedFuture(exception); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture( + operationFailure("Failed to validate parcel collection", throwable)); + } } @Override @@ -116,4 +124,98 @@ public CompletableFuture delete(UUID uuid) { public CompletableFuture delete(Parcel parcel) { return this.delegate.delete(parcel); } + + private CompletableFuture submitAsync( + Supplier> operation, + String failureMessage + ) { + CompletableFuture result = new CompletableFuture<>(); + try { + this.scheduler.runAsync(() -> { + try { + operation.get().whenComplete((value, throwable) -> { + if (throwable != null) { + result.completeExceptionally( + operationFailure(failureMessage, throwable)); + } else { + result.complete(value); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally( + operationFailure(failureMessage, throwable)); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally(operationFailure(failureMessage, throwable)); + } + return result; + } + + private static void validateSend(Player sender, Parcel parcel, List items) { + if (sender == null) { + throw new ValidationException("Sender cannot be null"); + } + validateParcel(parcel); + if (!sender.getUniqueId().equals(parcel.sender())) { + throw new ValidationException("Sender does not own the parcel"); + } + if (parcel.status() != ParcelStatus.SENT) { + throw new ValidationException("Parcel must have SENT status"); + } + if (items == null || items.isEmpty()) { + throw new ValidationException("Items cannot be null or empty"); + } + if (items.stream().anyMatch(item -> item == null)) { + throw new ValidationException("Items cannot contain null elements"); + } + } + + private static void validateCollect(Player player, Parcel parcel) { + if (player == null) { + throw new ValidationException("Player cannot be null"); + } + validateParcel(parcel); + if (!player.getUniqueId().equals(parcel.receiver())) { + throw new ValidationException("Player is not the parcel receiver"); + } + if (parcel.status() != ParcelStatus.DELIVERED) { + throw new ValidationException("Parcel must have DELIVERED status"); + } + } + + private static void validateParcel(Parcel parcel) { + if (parcel == null) { + throw new ValidationException("Parcel cannot be null"); + } + if (parcel.uuid() == null + || parcel.sender() == null + || parcel.name() == null + || parcel.name().isBlank() + || parcel.receiver() == null + || parcel.size() == null + || parcel.entryLocker() == null + || parcel.destinationLocker() == null + || parcel.status() == null) { + throw new ValidationException("Parcel has missing required fields"); + } + } + + private static ParcelOperationException operationFailure( + String message, + Throwable throwable + ) { + Throwable cause = unwrap(throwable); + if (cause instanceof ParcelOperationException operationException) { + return operationException; + } + return new ParcelOperationException(message, cause); + } + + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof CompletionException && throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable; + } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index ad8d0265..8ea277e7 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -85,7 +85,10 @@ public CompletableFuture dispatch(Player sender, Parcel parcel, List dispatchInternal( return CompletableFuture.completedFuture(false); } - return reservation.delete() - // A failed delete must trigger the rollback, not skip straight to the outer - // exceptionally handler (which would leave the parcel sent and the fee charged). - .exceptionally(throwable -> false) - .thenCompose(deleted -> { + return this.deleteStorage(reservation) + .handle((deleted, throwable) -> { + if (throwable != null) { + return this.compensate( + sender, + parcel, + "Failed to delete sender storage for parcel " + + parcel.uuid(), + unwrap(throwable), + List.of(this.rollbackStep(sender, parcel)) + ); + } if (!Boolean.TRUE.equals(deleted)) { - // The parcel and its content were already persisted and the fee charged, - // but the sender's staged storage could not be cleared. Fully roll back - // (parcel + content + fee) instead of leaving orphaned content behind. - return this.rollback(sender, parcel); + return this.compensate( + sender, + parcel, + "Sender storage was not deleted for parcel " + + parcel.uuid(), + new IllegalStateException( + "Sender storage delete returned false"), + List.of(this.rollbackStep(sender, parcel)) + ); } - - return this.createDelivery(sender, parcel, items, delay, reservation); - }); + return this.createDelivery( + sender, parcel, items, delay, reservation); + }) + .thenCompose(Function.identity()); }); }) - .exceptionally(throwable -> { - Throwable cause = unwrap(throwable); - if (cause instanceof CompensationException compensationException) { - LOGGER.log( - Level.SEVERE, - "Failed to compensate parcel " + parcel.uuid() - + " for sender " + sender.getUniqueId(), - compensationException); - this.notifyCannotSend(sender); - throw compensationException; + .handle((result, throwable) -> { + if (throwable == null) { + return CompletableFuture.completedFuture(result); } - LOGGER.severe("Failed to dispatch parcel for player " + sender.getName() + ": " + throwable.getMessage()); + ParcelOperationException failure = this.operationFailure( + "Failed to dispatch parcel " + parcel.uuid() + + " for sender " + sender.getUniqueId(), + throwable + ); + LOGGER.log( + Level.SEVERE, + "Failed to dispatch parcel " + parcel.uuid() + + " for sender " + sender.getUniqueId(), + failure); this.notifyCannotSend(sender); - return false; - }); + return CompletableFuture.failedFuture(failure); + }) + .thenCompose(Function.identity()); } private CompletableFuture createDelivery( @@ -162,7 +181,16 @@ private CompletableFuture createDelivery( return deliveryCreated.handle((delivery, throwable) -> { if (throwable != null) { - return this.restoreAndRollback(sender, parcel, items, reservation); + return this.compensate( + sender, + parcel, + "Failed to persist delivery for parcel " + parcel.uuid(), + unwrap(throwable), + List.of( + this.restoreStep(reservation, items), + this.rollbackStep(sender, parcel) + ) + ); } return this.scheduleDelivery(sender, parcel, items, delay, reservation); }).thenCompose(Function.identity()); @@ -185,7 +213,17 @@ private CompletableFuture scheduleDelivery( try { this.scheduler.runLaterAsync(task, delay); } catch (Throwable throwable) { - return this.deleteDeliveryRestoreAndRollback(sender, parcel, items, reservation); + return this.compensate( + sender, + parcel, + "Failed to schedule delivery for parcel " + parcel.uuid(), + throwable, + List.of( + this.deliveryDeleteStep(parcel), + this.restoreStep(reservation, items), + this.rollbackStep(sender, parcel) + ) + ); } try { @@ -197,78 +235,86 @@ private CompletableFuture scheduleDelivery( return CompletableFuture.completedFuture(true); } - private CompletableFuture deleteDeliveryRestoreAndRollback( + private CompletableFuture compensate( Player sender, Parcel parcel, - List items, - ItemStorageReservation reservation + String message, + Throwable trigger, + List steps ) { - return this.compensationStep( - () -> this.deliveryManager.delete(parcel.uuid()), - "Failed to delete delivery " + parcel.uuid() + " during dispatch compensation" - ) - .thenCompose(deleted -> Boolean.TRUE.equals(deleted) - ? CompletableFuture.completedFuture(null) - : CompletableFuture.failedFuture(new CompensationException( - "Delivery " + parcel.uuid() + " was not deleted during dispatch compensation" - ))) - .thenCompose(ignored -> this.restoreStorage(reservation, items)) - .thenCompose(ignored -> this.rollbackParcel(sender, parcel)) - .thenApply(ignored -> this.compensatedFailure(sender)); + ParcelOperationException failure = + new ParcelOperationException(message, unwrap(trigger)); + CompletableFuture cleanup = CompletableFuture.completedFuture(null); + for (CleanupStep step : steps) { + cleanup = cleanup.thenCompose(ignored -> + this.attemptCleanup(step, failure)); + } + return cleanup.thenCompose(ignored -> CompletableFuture.failedFuture(failure)); } - private CompletableFuture restoreAndRollback( - Player sender, - Parcel parcel, - List items, + private CompletableFuture deleteStorage( ItemStorageReservation reservation ) { - return this.restoreStorage(reservation, items) - .thenCompose(ignored -> this.rollbackParcel(sender, parcel)) - .thenApply(ignored -> this.compensatedFailure(sender)); + try { + return reservation.delete(); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + private CompletableFuture attemptCleanup( + CleanupStep step, + ParcelOperationException failure + ) { + CompletableFuture action; + try { + action = step.action().get(); + } catch (Throwable throwable) { + failure.addSuppressed(unwrap(throwable)); + return CompletableFuture.completedFuture(null); + } + if (action == null) { + failure.addSuppressed(new IllegalStateException( + step.description() + " returned a null future")); + return CompletableFuture.completedFuture(null); + } + return action.handle((ignored, throwable) -> { + if (throwable != null) { + failure.addSuppressed(unwrap(throwable)); + } + return null; + }); } - private CompletableFuture rollback(Player sender, Parcel parcel) { - return this.rollbackParcel(sender, parcel) - .thenApply(ignored -> this.compensatedFailure(sender)); + private CleanupStep deliveryDeleteStep(Parcel parcel) { + return new CleanupStep( + "Delete delivery " + parcel.uuid(), + () -> this.deliveryManager.delete(parcel.uuid()).thenCompose(deleted -> + Boolean.TRUE.equals(deleted) + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new IllegalStateException( + "Delivery " + parcel.uuid() + + " was not deleted during dispatch compensation"))) + ); } - private CompletableFuture restoreStorage( + private CleanupStep restoreStep( ItemStorageReservation reservation, List items ) { - return this.compensationStep( - () -> reservation.restore(items), - "Failed to restore item storage for player " + reservation.owner() + return new CleanupStep( + "Restore sender storage " + reservation.owner(), + () -> reservation.restore(items) ); } - private boolean compensatedFailure(Player sender) { - this.notifyCannotSend(sender); - return false; - } - - private CompletableFuture rollbackParcel(Player sender, Parcel parcel) { - return this.compensationStep( - () -> this.parcelService.rollbackSend(sender, parcel), - "Failed to roll back parcel " + parcel.uuid() + private CleanupStep rollbackStep(Player sender, Parcel parcel) { + return new CleanupStep( + "Roll back parcel " + parcel.uuid(), + () -> this.parcelService.rollbackSend(sender, parcel) ); } - private CompletableFuture compensationStep( - Supplier> action, - String failureMessage - ) { - try { - return action.get().exceptionallyCompose(throwable -> - CompletableFuture.failedFuture( - new CompensationException(failureMessage, unwrap(throwable)))); - } catch (Throwable throwable) { - return CompletableFuture.failedFuture( - new CompensationException(failureMessage, unwrap(throwable))); - } - } - private void notifyCannotSend(Player sender) { try { this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); @@ -285,14 +331,17 @@ private static Throwable unwrap(Throwable throwable) { return throwable; } - private static final class CompensationException extends ParcelOperationException { - - private CompensationException(String message) { - super(message); + private ParcelOperationException operationFailure(String message, Throwable throwable) { + Throwable cause = unwrap(throwable); + if (cause instanceof ParcelOperationException operationException) { + return operationException; } + return new ParcelOperationException(message, cause); + } - private CompensationException(String message, Throwable cause) { - super(message, cause); - } + private record CleanupStep( + String description, + Supplier> action + ) { } } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index 5cc90530..6a6aa6f2 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -23,6 +23,7 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.base.Preconditions; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -126,22 +127,56 @@ public CompletableFuture send(Player sender, Parcel parcel, List this.parcelContentRepository.save(new ParcelContent(parcel.uuid(), itemsCopy)) + CompletableFuture persistence; + try { + persistence = this.parcelRepository.save(parcel) + .thenCompose(unused -> this.parcelContentRepository.save( + new ParcelContent(parcel.uuid(), itemsCopy)) .thenApply(contentSaved -> { this.parcelsByUuid.put(parcel.uuid(), parcel); // The "sent" notice is issued by the dispatcher once the whole send succeeds, so it // is not shown when a later step (e.g. clearing storage) fails and rolls back. return true; - }) - .exceptionallyCompose(contentError -> this.parcelRepository.delete(parcel.uuid()) - .thenCompose(deleted -> CompletableFuture.failedFuture(new ParcelOperationException("Failed to save parcel content, rolled back parcel", contentError)))) + })); + } catch (Throwable throwable) { + persistence = CompletableFuture.failedFuture(throwable); + } + return persistence.exceptionallyCompose(throwable -> + this.compensatePersistenceFailure( + sender, parcel, refundableFee, unwrap(throwable))); + } + + private CompletableFuture compensatePersistenceFailure( + Player sender, + Parcel parcel, + double refundableFee, + Throwable trigger + ) { + ParcelOperationException failure = new ParcelOperationException( + "Failed to persist parcel " + parcel.uuid(), + trigger + ); + List cleanupFailures = new ArrayList<>(); + this.parcelsByUuid.invalidate(parcel.uuid()); + + return this.attemptRollback( + () -> this.parcelRepository.delete(parcel.uuid()), + "Delete parcel " + parcel.uuid(), + cleanupFailures ) - .exceptionally(throwable -> { - // Persistence failed after the fee was withdrawn - refund it so the player is not charged for a parcel that was never created. + .thenCompose(ignored -> this.attemptRollback( + () -> this.parcelContentRepository.delete(parcel.uuid()), + "Delete parcel content " + parcel.uuid(), + cleanupFailures + )) + .thenCompose(ignored -> this.attemptRollback(() -> { this.refundFee(sender, refundableFee); - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - throw new ParcelOperationException("Failed to save parcel", throwable); + return CompletableFuture.completedFuture(null); + }, "Refund parcel fee for " + parcel.uuid(), cleanupFailures)) + .thenCompose(ignored -> { + cleanupFailures.forEach(failure::addSuppressed); + this.notifyCannotSend(sender); + return CompletableFuture.failedFuture(failure); }); } @@ -150,14 +185,64 @@ public CompletableFuture rollbackSend(Player sender, Parcel parcel) { Objects.requireNonNull(sender, "Sender cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); + List failures = new ArrayList<>(); if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { - this.refundFee(sender, this.feeFor(parcel.size())); + try { + this.refundFee(sender, this.feeFor(parcel.size())); + } catch (Throwable throwable) { + failures.add(unwrap(throwable)); + } } this.parcelsByUuid.invalidate(parcel.uuid()); - return this.parcelRepository.delete(parcel.uuid()) - .thenCompose(deleted -> this.parcelContentRepository.delete(parcel.uuid())) - .thenApply(contentDeleted -> null); + return this.attemptRollback( + () -> this.parcelRepository.delete(parcel.uuid()), + "Delete parcel " + parcel.uuid(), + failures + ) + .thenCompose(ignored -> this.attemptRollback( + () -> this.parcelContentRepository.delete(parcel.uuid()), + "Delete parcel content " + parcel.uuid(), + failures + )) + .thenCompose(ignored -> { + if (failures.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + ParcelOperationException failure = new ParcelOperationException( + "Failed to completely roll back parcel " + parcel.uuid(), + failures.get(0) + ); + failures.stream().skip(1).forEach(failure::addSuppressed); + return CompletableFuture.failedFuture(failure); + }); + } + + private CompletableFuture attemptRollback( + java.util.function.Supplier> action, + String description, + List failures + ) { + CompletableFuture result; + try { + result = action.get(); + } catch (Throwable throwable) { + failures.add(unwrap(throwable)); + return CompletableFuture.completedFuture(null); + } + if (result == null) { + failures.add(new IllegalStateException("Rollback action returned a null future")); + return CompletableFuture.completedFuture(null); + } + return result.handle((value, throwable) -> { + if (throwable != null) { + failures.add(unwrap(throwable)); + } else if (value instanceof Boolean completed && !completed) { + failures.add(new IllegalStateException( + description + " returned false")); + } + return null; + }); } private double feeFor(ParcelSize size) { @@ -170,7 +255,31 @@ private double feeFor(ParcelSize size) { private void refundFee(Player sender, double fee) { if (fee > 0) { - this.economy.depositPlayer(sender, fee); + if (!this.economy.depositPlayer(sender, fee).transactionSuccess()) { + throw new IllegalStateException( + "Economy provider rejected parcel fee refund"); + } + } + } + + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof java.util.concurrent.CompletionException + && throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable; + } + + private void notifyCannotSend(Player sender) { + try { + this.noticeService.player( + sender.getUniqueId(), messages -> messages.parcel.cannotSend); + } catch (Throwable throwable) { + if (this.server.getLogger() != null) { + this.server.getLogger().warning( + "Failed to send parcel failure notice to " + sender.getUniqueId() + + ": " + throwable.getMessage()); + } } } @@ -222,65 +331,110 @@ public CompletableFuture collect(Player player, Parcel parcel) { Objects.requireNonNull(player, "Player cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); - // Fire ParcelCollectEvent - ParcelCollectEvent event = new ParcelCollectEvent(parcel); - this.server.getPluginManager().callEvent(event); - - if (event.isCancelled()) { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); - return CompletableFuture.completedFuture(null); - } - - return this.parcelContentRepository.find(parcel.uuid()).thenCompose(optional -> { - if (optional.isEmpty()) { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); + return this.fireCollectEvent(parcel).thenCompose(cancelled -> { + if (cancelled) { + this.noticeService.player( + player.getUniqueId(), messages -> messages.parcel.cannotCollect); return CompletableFuture.completedFuture(null); } - List items = optional.get().items(); - CompletableFuture result = new CompletableFuture<>(); - - // Re-check inventory space on the main thread (the previous async check was a TOCTOU), - // 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); - result.complete(null); - return; + return this.parcelContentRepository.find(parcel.uuid()).thenCompose(optional -> { + if (optional.isEmpty()) { + this.noticeService.player( + player.getUniqueId(), messages -> messages.parcel.cannotCollect); + return CompletableFuture.completedFuture(null); } - 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); + List items = optional.get().items(); + CompletableFuture result = new CompletableFuture<>(); + + // Re-check inventory space on the main thread (the previous async check was a TOCTOU), + // 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 + ); + result.complete(null); + return; + } + + 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.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 + ); + }); result.complete(null); - return; - } - - 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); + }) + .exceptionally(throwable -> { + this.noticeService.player( + player.getUniqueId(), + messages -> messages.parcel.cannotCollect + ); + result.complete(null); + return null; }); - result.complete(null); - }) - .exceptionally(throwable -> { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); - result.complete(null); - return null; - }); - }); + }); - return result; + return result; + }); }); } + private CompletableFuture fireCollectEvent(Parcel parcel) { + if (this.server.isPrimaryThread()) { + try { + ParcelCollectEvent event = new ParcelCollectEvent(parcel); + this.server.getPluginManager().callEvent(event); + return CompletableFuture.completedFuture(event.isCancelled()); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + CompletableFuture result = new CompletableFuture<>(); + try { + this.scheduler.run(() -> { + try { + ParcelCollectEvent event = new ParcelCollectEvent(parcel); + this.server.getPluginManager().callEvent(event); + result.complete(event.isCancelled()); + } catch (Throwable throwable) { + result.completeExceptionally(throwable); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally(throwable); + } + return result; + } + 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(), diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java index 2dde4693..8e394e88 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java @@ -1,10 +1,14 @@ package com.eternalcode.parcellockers; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -12,9 +16,16 @@ import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; import com.eternalcode.parcellockers.parcel.service.PluginParcelService; +import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import com.eternalcode.parcellockers.shared.exception.ValidationException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.bukkit.entity.Player; @@ -24,48 +35,200 @@ class PublicParcelServiceTest { + @Test + void sendRejectsSenderMismatchBeforeSchedulingDispatch() { + Fixture fixture = new Fixture(); + when(fixture.sender.getUniqueId()).thenReturn(UUID.randomUUID()); + + CompletableFuture result = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, result::join); + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(fixture.scheduler, never()).runAsync(any()); + verify(fixture.dispatcher, never()).dispatch(any(), any(), any()); + } + + @Test + void sendRejectsNonSentParcelBeforeSchedulingDispatch() { + Fixture fixture = new Fixture(); + Parcel delivered = withStatus(fixture.parcel, ParcelStatus.DELIVERED); + + CompletableFuture result = + fixture.service.send(fixture.sender, delivered, fixture.items); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, result::join); + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(fixture.scheduler, never()).runAsync(any()); + verify(fixture.dispatcher, never()).dispatch(any(), any(), any()); + } + + @Test + void sendRejectsNullEmptyAndNullElementItemsThroughFailedFuture() { + Fixture fixture = new Fixture(); + + for (List invalid : List.of( + List.of(), Arrays.asList((ItemStack) null))) { + CompletableFuture result = assertDoesNotThrow( + () -> fixture.service.send(fixture.sender, fixture.parcel, invalid)); + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = assertThrows(CompletionException.class, result::join); + assertInstanceOf(ValidationException.class, exception.getCause()); + } + CompletableFuture nullResult = assertDoesNotThrow( + () -> fixture.service.send(fixture.sender, fixture.parcel, null)); + assertTrue(nullResult.isCompletedExceptionally()); + assertInstanceOf(ValidationException.class, + assertThrows(CompletionException.class, nullResult::join).getCause()); + verify(fixture.scheduler, never()).runAsync(any()); + } + + @Test + void sendFreezesClonedItemsBeforeAsyncSubmission() { + Fixture fixture = new Fixture(); + ItemStack original = mock(ItemStack.class); + ItemStack snapshot = mock(ItemStack.class); + when(original.clone()).thenReturn(snapshot); + List callerItems = new ArrayList<>(List.of(original)); + when(fixture.dispatcher.dispatch(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + CompletableFuture result = + fixture.service.send(fixture.sender, fixture.parcel, callerItems); + callerItems.clear(); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runAsync(task.capture()); + task.getValue().run(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> capturedItems = ArgumentCaptor.forClass(List.class); + verify(fixture.dispatcher).dispatch( + eq(fixture.sender), eq(fixture.parcel), capturedItems.capture()); + assertEquals(List.of(snapshot), capturedItems.getValue()); + assertThrows(UnsupportedOperationException.class, + () -> capturedItems.getValue().add(mock(ItemStack.class))); + assertTrue(result.join()); + } + + @Test + void collectRejectsReceiverMismatchAndInvalidStatusBeforeScheduling() { + Fixture fixture = new Fixture(); + when(fixture.receiver.getUniqueId()).thenReturn(UUID.randomUUID()); + + CompletableFuture mismatchResult = fixture.service.collect( + fixture.receiver, withStatus(fixture.parcel, ParcelStatus.DELIVERED)); + assertTrue(mismatchResult.isCompletedExceptionally()); + CompletionException mismatch = + assertThrows(CompletionException.class, mismatchResult::join); + assertInstanceOf(ValidationException.class, mismatch.getCause()); + + when(fixture.receiver.getUniqueId()).thenReturn(fixture.parcel.receiver()); + CompletableFuture statusResult = + fixture.service.collect(fixture.receiver, fixture.parcel); + assertTrue(statusResult.isCompletedExceptionally()); + CompletionException status = + assertThrows(CompletionException.class, statusResult::join); + assertInstanceOf(ValidationException.class, status.getCause()); + verify(fixture.scheduler, never()).runAsync(any()); + verify(fixture.delegate, never()).collect(any(), any()); + } + + @Test + void collectDefersDelegateWorkOffCallingThread() { + Fixture fixture = new Fixture(); + Parcel delivered = withStatus(fixture.parcel, ParcelStatus.DELIVERED); + CompletableFuture collected = new CompletableFuture<>(); + when(fixture.delegate.collect(fixture.receiver, delivered)).thenReturn(collected); + + CompletableFuture result = assertDoesNotThrow( + () -> fixture.service.collect(fixture.receiver, delivered)); + + assertFalse(result.isDone()); + verify(fixture.delegate, never()).collect(any(), any()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runAsync(task.capture()); + task.getValue().run(); + verify(fixture.delegate).collect(fixture.receiver, delivered); + collected.complete(null); + result.join(); + } + @Test void sendDefersTheCompleteDispatchWorkflowOffTheCallingThread() { - PluginParcelService delegate = mock(PluginParcelService.class); - ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); - Scheduler scheduler = mock(Scheduler.class); - PublicParcelService service = new PublicParcelService(delegate, dispatcher, scheduler); - Player sender = mock(Player.class); - Parcel parcel = mock(Parcel.class); - List items = List.of(mock(ItemStack.class)); + Fixture fixture = new Fixture(); CompletableFuture dispatched = new CompletableFuture<>(); - when(dispatcher.dispatch(sender, parcel, items)).thenReturn(dispatched); + when(fixture.dispatcher.dispatch( + fixture.sender, fixture.parcel, fixture.items)).thenReturn(dispatched); - CompletableFuture result = service.send(sender, parcel, items); + CompletableFuture result = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); assertFalse(result.isDone()); - verify(dispatcher, never()).dispatch(sender, parcel, items); + verify(fixture.dispatcher, never()).dispatch(any(), any(), any()); ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); - verify(scheduler).runAsync(task.capture()); + verify(fixture.scheduler).runAsync(task.capture()); task.getValue().run(); - verify(dispatcher).dispatch(sender, parcel, items); + verify(fixture.dispatcher).dispatch( + fixture.sender, fixture.parcel, fixture.items); dispatched.complete(true); assertSame(Boolean.TRUE, result.join()); - verify(delegate, never()).send(sender, parcel, items); + verify(fixture.delegate, never()).send(any(), any(), any()); } @Test void schedulerSubmissionFailureCompletesFutureExceptionallyWithoutSynchronousThrow() { - PluginParcelService delegate = mock(PluginParcelService.class); - ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); - Scheduler scheduler = mock(Scheduler.class); - PublicParcelService service = new PublicParcelService(delegate, dispatcher, scheduler); - Player sender = mock(Player.class); - Parcel parcel = mock(Parcel.class); - List items = List.of(mock(ItemStack.class)); - when(scheduler.runAsync(any())) + Fixture fixture = new Fixture(); + when(fixture.scheduler.runAsync(any())) .thenThrow(new IllegalStateException("submission failed")); - CompletableFuture result = assertDoesNotThrow(() -> service.send(sender, parcel, items)); + CompletableFuture result = assertDoesNotThrow( + () -> fixture.service.send(fixture.sender, fixture.parcel, fixture.items)); + + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertInstanceOf(IllegalStateException.class, exception.getCause().getCause()); + verify(fixture.dispatcher, never()).dispatch(any(), any(), any()); + } + + 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); + } + + private static final class Fixture { + + private final PluginParcelService delegate = mock(PluginParcelService.class); + private final ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final PublicParcelService service = + new PublicParcelService(this.delegate, this.dispatcher, this.scheduler); + private final Player sender = mock(Player.class); + private final Player receiver = mock(Player.class); + private final Parcel parcel = new Parcel( + UUID.randomUUID(), + UUID.randomUUID(), + "name", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.SENT + ); + private final List items = List.of(mock(ItemStack.class)); - assertThrows(CompletionException.class, result::join); - verify(dispatcher, never()).dispatch(sender, parcel, items); + private Fixture() { + when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); + when(this.receiver.getUniqueId()).thenReturn(this.parcel.receiver()); + when(this.items.get(0).clone()).thenReturn(this.items.get(0)); + } } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java index b67f95b7..05c2da3b 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -109,6 +110,20 @@ void fullLockerCompletesFalseWithoutPersistingParcel() { verify(this.reservation).close(); } + @Test + void fullnessLookupFailureCompletesExceptionally() { + IllegalStateException lookupFailure = new IllegalStateException("lookup failed"); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.failedFuture(lookupFailure)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(lookupFailure, exception.getCause().getCause()); + verify(this.parcelService, never()).send(any(), any(), any()); + } + @Test void cancelledPersistenceStepCompletesFalseWithoutContinuing() { when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) @@ -122,25 +137,68 @@ void cancelledPersistenceStepCompletesFalseWithoutContinuing() { } @Test - void storageDeleteFailureRollsBackAndCompletesFalse() { + void parcelPersistenceFailureCompletesExceptionally() { + IllegalStateException persistenceFailure = + new IllegalStateException("persistence failed"); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.failedFuture(persistenceFailure)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(persistenceFailure, exception.getCause().getCause()); + verify(this.reservation, never()).delete(); + } + + @Test + void storageDeleteFailureRollsBackAndCompletesExceptionally() { + IllegalStateException deleteFailure = new IllegalStateException("delete failed"); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()) - .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("delete failed"))); + .thenReturn(CompletableFuture.failedFuture(deleteFailure)); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); - assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(deleteFailure, exception.getCause().getCause()); verify(this.parcelService).rollbackSend(this.sender, this.parcel); verify(this.deliveryManager, never()).create(any(), any()); verify(this.scheduler, never()).runLaterAsync(any(), any()); } @Test - void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { + void synchronousStorageDeleteFailureStillRollsBack() { + IllegalStateException deleteFailure = new IllegalStateException("delete failed"); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.reservation.delete()).thenThrow(deleteFailure); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(deleteFailure, operationException.getCause()); + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.deliveryManager, never()).create(any(), any()); + } + + @Test + void synchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { + IllegalStateException createFailure = new IllegalStateException("create failed"); CompletableFuture restored = new CompletableFuture<>(); CompletableFuture rolledBack = new CompletableFuture<>(); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) @@ -150,7 +208,7 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) - .thenThrow(new IllegalStateException("create failed")); + .thenThrow(createFailure); when(this.reservation.restore(this.items)) .thenReturn(restored); when(this.parcelService.rollbackSend(this.sender, this.parcel)) @@ -167,14 +225,17 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { verify(this.parcelService).rollbackSend(this.sender, this.parcel); rolledBack.complete(null); - - assertFalse(result.join()); + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(createFailure, exception.getCause().getCause()); verify(this.reservation).restore(this.items); verify(this.scheduler, never()).runLaterAsync(any(), any()); } @Test - void asynchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { + void asynchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { + IllegalStateException createFailure = new IllegalStateException("create failed"); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); when(this.parcelService.send(this.sender, this.parcel, this.items)) @@ -182,14 +243,17 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) - .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); + .thenReturn(CompletableFuture.failedFuture(createFailure)); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); when(this.parcelService.rollbackSend(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); - assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(createFailure, exception.getCause().getCause()); verify(this.reservation).restore(this.items); verify(this.parcelService).rollbackSend(this.sender, this.parcel); verify(this.scheduler, never()).runLaterAsync(any(), any()); @@ -197,7 +261,8 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesFalse() { } @Test - void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBack() { + void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBackExceptionally() { + IllegalStateException schedulingFailure = new IllegalStateException("schedule failed"); CompletableFuture deliveryDeleted = new CompletableFuture<>(); CompletableFuture restored = new CompletableFuture<>(); CompletableFuture rolledBack = new CompletableFuture<>(); @@ -205,7 +270,7 @@ void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBack() { when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) .thenReturn(CompletableFuture.completedFuture(mock(Delivery.class))); when(this.scheduler.runLaterAsync(any(), any())) - .thenThrow(new IllegalStateException("schedule failed")); + .thenThrow(schedulingFailure); when(this.deliveryManager.delete(this.parcel.uuid())).thenReturn(deliveryDeleted); when(this.reservation.restore(this.items)).thenReturn(restored); when(this.parcelService.rollbackSend(this.sender, this.parcel)).thenReturn(rolledBack); @@ -225,7 +290,10 @@ void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBack() { assertFalse(result.isDone()); rolledBack.complete(null); - assertFalse(result.join()); + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(schedulingFailure, exception.getCause().getCause()); } @Test @@ -252,6 +320,8 @@ public void close() { .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("restore failed"))); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception; try { @@ -262,7 +332,7 @@ public void close() { } assertInstanceOf(ParcelOperationException.class, exception.getCause()); - verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSend(this.sender, this.parcel); verify(this.noticeService, times(1)).player(eq(this.parcel.sender()), any()); verify(this.reservation).close(); assertTrue(records.stream().anyMatch(record -> @@ -280,13 +350,50 @@ void deliveryDeleteFailureCompletesExceptionallyInsteadOfFalse() { .thenThrow(new IllegalStateException("schedule failed")); when(this.deliveryManager.delete(this.parcel.uuid())) .thenReturn(CompletableFuture.completedFuture(false)); + when(this.reservation.restore(this.items)) + .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception = assertThrows(CompletionException.class, () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); assertInstanceOf(ParcelOperationException.class, exception.getCause()); - verify(this.reservation, never()).restore(this.items); - verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + verify(this.reservation).restore(this.items); + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + } + + @Test + void schedulingCompensationAttemptsEveryStepAndAggregatesCleanupFailures() { + IllegalStateException schedulingFailure = new IllegalStateException("schedule failed"); + IllegalStateException deliveryCleanupFailure = + new IllegalStateException("delivery cleanup failed"); + IllegalStateException restoreFailure = new IllegalStateException("restore failed"); + IllegalStateException rollbackFailure = new IllegalStateException("rollback failed"); + this.stubSuccessfulStart(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.completedFuture(mock(Delivery.class))); + when(this.scheduler.runLaterAsync(any(), any())).thenThrow(schedulingFailure); + when(this.deliveryManager.delete(this.parcel.uuid())) + .thenReturn(CompletableFuture.failedFuture(deliveryCleanupFailure)); + when(this.reservation.restore(this.items)) + .thenReturn(CompletableFuture.failedFuture(restoreFailure)); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.failedFuture(rollbackFailure)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(schedulingFailure, operationException.getCause()); + assertEquals( + List.of(deliveryCleanupFailure, restoreFailure, rollbackFailure), + List.of(operationException.getSuppressed()) + ); + verify(this.deliveryManager).delete(this.parcel.uuid()); + verify(this.reservation).restore(this.items); + verify(this.parcelService).rollbackSend(this.sender, this.parcel); } @Test @@ -371,7 +478,9 @@ void synchronousFailureBeforeLockerChainRegistrationReleasesReservation() { CompletionException exception = assertThrows(CompletionException.class, result::join); - assertEquals(failure, exception.getCause()); + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(failure, operationException.getCause()); verify(this.reservation).close(); verify(this.lockerManager, never()).isLockerFull(any()); } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java new file mode 100644 index 00000000..5c9b0450 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -0,0 +1,253 @@ +package com.eternalcode.parcellockers.parcel.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +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.repository.ParcelContentRepository; +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.repository.ParcelRepository; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import net.milkbowl.vault.economy.Economy; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ParcelServiceImplTest { + + @Test + void collectDefersSynchronousEventToMainSchedulerWhenCalledAsync() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = + mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + Player player = mock(Player.class); + Parcel parcel = new Parcel( + UUID.randomUUID(), + UUID.randomUUID(), + "name", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.DELIVERED + ); + when(server.isPrimaryThread()).thenReturn(false); + when(server.getPluginManager()).thenReturn(pluginManager); + when(contentRepository.find(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(java.util.Optional.empty())); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, + parcelRepository, + contentRepository, + collectedRepository, + scheduler, + config, + economy, + server + ); + + CompletableFuture result = service.collect(player, parcel); + + verify(pluginManager, never()).callEvent(any(ParcelCollectEvent.class)); + ArgumentCaptor eventTask = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).run(eventTask.capture()); + eventTask.getValue().run(); + verify(pluginManager).callEvent(any(ParcelCollectEvent.class)); + result.join(); + } + + @Test + void contentPersistenceFailureAttemptsParcelAndContentCleanupAndPreservesTrigger() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = + mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + Player sender = mock(Player.class); + Parcel parcel = parcel(); + IllegalStateException contentSaveFailure = + new IllegalStateException("content save failed"); + IllegalStateException parcelDeleteFailure = + new IllegalStateException("parcel delete failed"); + IllegalStateException contentDeleteFailure = + new IllegalStateException("content delete failed"); + when(sender.hasPermission("parcellockers.fee.bypass")).thenReturn(true); + when(server.getPluginManager()).thenReturn(pluginManager); + when(parcelRepository.save(parcel)).thenReturn(CompletableFuture.completedFuture(null)); + when(contentRepository.save(any())) + .thenReturn(CompletableFuture.failedFuture(contentSaveFailure)); + when(parcelRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.failedFuture(parcelDeleteFailure)); + when(contentRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.failedFuture(contentDeleteFailure)); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, + parcelRepository, + contentRepository, + collectedRepository, + scheduler, + config, + economy, + server + ); + + CompletionException exception = assertThrows(CompletionException.class, + () -> service.send(sender, parcel, List.of(mock(org.bukkit.inventory.ItemStack.class))) + .join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(contentSaveFailure, operationException.getCause()); + assertEquals( + List.of(parcelDeleteFailure, contentDeleteFailure), + List.of(operationException.getSuppressed()) + ); + verify(parcelRepository).delete(parcel.uuid()); + verify(contentRepository).delete(parcel.uuid()); + } + + @Test + void rollbackSendAttemptsFeeParcelAndContentCleanupAndAggregatesFailures() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = + mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + config.settings.smallParcelFee = 10.0; + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + Player sender = mock(Player.class); + Parcel parcel = parcel(); + IllegalStateException refundFailure = new IllegalStateException("refund failed"); + IllegalStateException parcelDeleteFailure = + new IllegalStateException("parcel delete failed"); + IllegalStateException contentDeleteFailure = + new IllegalStateException("content delete failed"); + when(sender.hasPermission("parcellockers.fee.bypass")).thenReturn(false); + doThrow(refundFailure).when(economy).depositPlayer(sender, 10.0); + when(parcelRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.failedFuture(parcelDeleteFailure)); + when(contentRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.failedFuture(contentDeleteFailure)); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, + parcelRepository, + contentRepository, + collectedRepository, + scheduler, + config, + economy, + server + ); + + CompletableFuture result = + assertDoesNotThrow(() -> service.rollbackSend(sender, parcel)); + CompletionException exception = + assertThrows(CompletionException.class, result::join); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(refundFailure, operationException.getCause()); + assertEquals( + List.of(parcelDeleteFailure, contentDeleteFailure), + List.of(operationException.getSuppressed()) + ); + verify(economy).depositPlayer(sender, 10.0); + verify(parcelRepository).delete(parcel.uuid()); + verify(contentRepository).delete(parcel.uuid()); + } + + @Test + void rollbackSendTreatsFalseDeleteResultsAsCleanupFailures() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = + mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + Player sender = mock(Player.class); + Parcel parcel = parcel(); + when(sender.hasPermission("parcellockers.fee.bypass")).thenReturn(true); + when(parcelRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(contentRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(false)); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, + parcelRepository, + contentRepository, + collectedRepository, + scheduler, + config, + economy, + server + ); + + CompletionException exception = assertThrows(CompletionException.class, + () -> service.rollbackSend(sender, parcel).join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertInstanceOf(IllegalStateException.class, operationException.getCause()); + assertEquals(1, operationException.getSuppressed().length); + assertInstanceOf( + IllegalStateException.class, operationException.getSuppressed()[0]); + verify(parcelRepository).delete(parcel.uuid()); + verify(contentRepository).delete(parcel.uuid()); + } + + private static Parcel parcel() { + return new Parcel( + UUID.randomUUID(), + UUID.randomUUID(), + "name", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.SENT + ); + } +} From d8356fed70e81dfe375d7025bc915bfa086742b6 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:54:06 +0200 Subject: [PATCH 16/19] fix: enforce parcel operation integrity --- .../parcel/repository/ParcelRepository.java | 27 ++ .../repository/ParcelRepositoryOrmLite.java | 89 +++- .../parcel/service/ParcelServiceImpl.java | 328 +++++++++++---- .../repository/CollectedParcelTable.java | 4 +- .../parcel/service/ParcelServiceImplTest.java | 389 +++++++++++++++++- 5 files changed, 752 insertions(+), 85 deletions(-) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java index 81713f21..c2fbfbc5 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -4,6 +4,7 @@ import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -14,6 +15,13 @@ public interface ParcelRepository { CompletableFuture save(Parcel parcel); + /** + * Atomically inserts a parcel if its UUID is not already present. + * + * @return true only when this call inserted the row + */ + CompletableFuture saveIfAbsent(Parcel parcel); + CompletableFuture update(Parcel parcel); /** @@ -58,6 +66,25 @@ public interface ParcelRepository { */ CompletableFuture markCollected(UUID uuid); + /** + * Atomically flips a receiver's DELIVERED parcel to COLLECTED. + */ + CompletableFuture markCollected(UUID uuid, UUID receiver); + + /** + * Atomically claims a delivered parcel and records the start of its return window. + */ + CompletableFuture commitCollection(UUID uuid, UUID receiver, Instant collectedAt); + + /** + * Restores a claimed collection after main-thread item delivery failed. + */ + CompletableFuture rollbackCollection( + UUID uuid, + UUID receiver, + Instant collectedAt + ); + /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ CompletableFuture> findReturnable(UUID receiver, Page page); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java index a459542d..b613e714 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -5,9 +5,14 @@ import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelTable; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; +import com.j256.ormlite.misc.TransactionManager; +import com.j256.ormlite.stmt.DeleteBuilder; import com.j256.ormlite.stmt.UpdateBuilder; +import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -38,6 +43,14 @@ public CompletableFuture save(Parcel parcel) { return this.insertIfAbsent(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } + @Override + public CompletableFuture saveIfAbsent(Parcel parcel) { + Objects.requireNonNull(parcel, "Parcel cannot be null"); + ParcelTable inserted = ParcelTable.from(parcel); + return this.insertIfAbsent(ParcelTable.class, inserted) + .thenApply(result -> result == inserted); + } + @Override public CompletableFuture update(Parcel parcel) { Objects.requireNonNull(parcel, "Parcel cannot be null"); @@ -128,18 +141,92 @@ public CompletableFuture> findCollectible(UUID receiver, UUID @Override public CompletableFuture markCollected(UUID uuid) { + return this.markCollected(uuid, null); + } + + @Override + public CompletableFuture markCollected(UUID uuid, UUID receiver) { 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() + var where = builder.where() .eq(UUID_COLUMN, uuid) .and() .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); + if (receiver != null) { + where.and().eq(RECEIVER_COLUMN, receiver); + } return builder.update() > 0; }); } + @Override + public CompletableFuture commitCollection( + UUID uuid, + UUID receiver, + Instant collectedAt + ) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(collectedAt, "Collected timestamp cannot be null"); + return this.action(ParcelTable.class, parcelDao -> + TransactionManager.callInTransaction(this.databaseManager.connectionSource(), () -> { + UpdateBuilder update = parcelDao.updateBuilder(); + update.updateColumnValue(STATUS_COLUMN, ParcelStatus.COLLECTED); + update.where() + .eq(UUID_COLUMN, uuid) + .and() + .eq(RECEIVER_COLUMN, receiver) + .and() + .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); + if (update.update() == 0) { + return false; + } + this.databaseManager.getDao(CollectedParcelTable.class).createOrUpdate( + CollectedParcelTable.from(new CollectedParcel(uuid, collectedAt))); + return true; + })); + } + + @Override + public CompletableFuture rollbackCollection( + UUID uuid, + UUID receiver, + Instant collectedAt + ) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(collectedAt, "Collected timestamp cannot be null"); + return this.action(ParcelTable.class, parcelDao -> + TransactionManager.callInTransaction(this.databaseManager.connectionSource(), () -> { + UpdateBuilder update = parcelDao.updateBuilder(); + update.updateColumnValue(STATUS_COLUMN, ParcelStatus.DELIVERED); + update.where() + .eq(UUID_COLUMN, uuid) + .and() + .eq(RECEIVER_COLUMN, receiver) + .and() + .eq(STATUS_COLUMN, ParcelStatus.COLLECTED); + if (update.update() == 0) { + return false; + } + + var collectedDao = this.databaseManager.getDao(CollectedParcelTable.class); + DeleteBuilder delete = + collectedDao.deleteBuilder(); + delete.where() + .eq("parcel", uuid) + .and() + .eq(CollectedParcelTable.COLLECTED_AT_COLUMN, collectedAt); + if (delete.delete() != 1) { + throw new IllegalStateException( + "Collection token changed while rolling back parcel " + uuid); + } + return true; + })); + } + @Override public CompletableFuture> findReturnable(UUID receiver, Page page) { Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index 6a6aa6f2..be0cd199 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -14,16 +14,17 @@ 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.eternalcode.parcellockers.shared.exception.ValidationException; 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.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -100,50 +101,93 @@ public CompletableFuture send(Player sender, Parcel parcel, List { + if (!Boolean.TRUE.equals(inserted)) { + return CompletableFuture.failedFuture(new ValidationException( + "Parcel " + parcel.uuid() + " already exists")); + } + return this.persistReservedParcel(sender, parcel, itemsCopy); + }) + .exceptionallyCompose(throwable -> { + Throwable cause = unwrap(throwable); + if (cause instanceof ValidationException validationException) { + return CompletableFuture.failedFuture(validationException); + } + if (cause instanceof ParcelOperationException operationException) { + return CompletableFuture.failedFuture(operationException); + } + return CompletableFuture.failedFuture(new ParcelOperationException( + "Failed to reserve parcel " + parcel.uuid(), cause)); + }); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(new ParcelOperationException( + "Failed to reserve parcel " + parcel.uuid(), unwrap(throwable))); + } + } - if (fee > 0) { - boolean success = this.economy.withdrawPlayer(sender, fee).transactionSuccess(); - String formattedFee = String.format("%.2f", fee); + private CompletableFuture persistReservedParcel( + Player sender, + Parcel parcel, + List items + ) { + double chargedFee = 0; + try { + if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { + double fee = this.feeFor(parcel.size()); + if (fee > 0) { + boolean success = this.economy.withdrawPlayer(sender, fee).transactionSuccess(); + String formattedFee = String.format("%.2f", fee); + if (!success) { + this.notifyInsufficientFunds(sender, formattedFee); + return this.removeReservation(parcel).thenApply(ignored -> false); + } - if (!success) { + chargedFee = fee; this.noticeService.create() - .notice(messages -> messages.parcel.insufficientFunds) + .notice(messages -> messages.parcel.feeWithdrawn) .player(sender.getUniqueId()) .placeholder(PLACEHOLDER_AMOUNT, formattedFee) .send(); - return CompletableFuture.completedFuture(false); } - - chargedFee = fee; - this.noticeService.create() - .notice(messages -> messages.parcel.feeWithdrawn) - .player(sender.getUniqueId()) - .placeholder(PLACEHOLDER_AMOUNT, formattedFee) - .send(); } - } - double refundableFee = chargedFee; - CompletableFuture persistence; - try { - persistence = this.parcelRepository.save(parcel) - .thenCompose(unused -> this.parcelContentRepository.save( - new ParcelContent(parcel.uuid(), itemsCopy)) + double refundableFee = chargedFee; + return this.parcelContentRepository.save(new ParcelContent(parcel.uuid(), items)) .thenApply(contentSaved -> { this.parcelsByUuid.put(parcel.uuid(), parcel); - // The "sent" notice is issued by the dispatcher once the whole send succeeds, so it - // is not shown when a later step (e.g. clearing storage) fails and rolls back. return true; - })); + }) + .exceptionallyCompose(throwable -> this.compensatePersistenceFailure( + sender, parcel, refundableFee, unwrap(throwable))); } catch (Throwable throwable) { - persistence = CompletableFuture.failedFuture(throwable); + return this.compensatePersistenceFailure( + sender, parcel, chargedFee, unwrap(throwable)); + } + } + + private CompletableFuture removeReservation(Parcel parcel) { + return this.parcelRepository.delete(parcel.uuid()).thenCompose(deleted -> { + if (Boolean.TRUE.equals(deleted)) { + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.failedFuture(new ParcelOperationException( + "Failed to remove unpaid parcel reservation " + parcel.uuid(), + new IllegalStateException("Parcel reservation delete returned false"))); + }); + } + + private void notifyInsufficientFunds(Player sender, String formattedFee) { + try { + this.noticeService.create() + .notice(messages -> messages.parcel.insufficientFunds) + .player(sender.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, formattedFee) + .send(); + } catch (Throwable ignored) { + // A presentation failure must not change the business outcome. } - return persistence.exceptionallyCompose(throwable -> - this.compensatePersistenceFailure( - sender, parcel, refundableFee, unwrap(throwable))); } private CompletableFuture compensatePersistenceFailure( @@ -186,12 +230,12 @@ public CompletableFuture rollbackSend(Player sender, Parcel parcel) { Objects.requireNonNull(parcel, "Parcel cannot be null"); List failures = new ArrayList<>(); - if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { - try { + try { + if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { this.refundFee(sender, this.feeFor(parcel.size())); - } catch (Throwable throwable) { - failures.add(unwrap(throwable)); } + } catch (Throwable throwable) { + failures.add(unwrap(throwable)); } this.parcelsByUuid.invalidate(parcel.uuid()); @@ -331,82 +375,204 @@ public CompletableFuture collect(Player player, Parcel parcel) { Objects.requireNonNull(player, "Player cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); + UUID playerId = player.getUniqueId(); + CompletableFuture> authoritativeFuture; + try { + authoritativeFuture = this.parcelRepository.findById(parcel.uuid()); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(new ParcelOperationException( + "Failed to load parcel " + parcel.uuid(), unwrap(throwable))); + } + return authoritativeFuture.thenCompose(optional -> { + Parcel authoritative = optional.orElseThrow(() -> + new ValidationException("Parcel " + parcel.uuid() + " does not exist")); + if (!playerId.equals(authoritative.receiver())) { + throw new ValidationException("Player is not the parcel receiver"); + } + if (authoritative.status() != ParcelStatus.DELIVERED) { + throw new ValidationException("Parcel must have DELIVERED status"); + } + return this.collectAuthoritative(player, authoritative); + }).exceptionallyCompose(throwable -> { + Throwable cause = unwrap(throwable); + if (cause instanceof ValidationException validationException) { + return CompletableFuture.failedFuture(validationException); + } + if (cause instanceof ParcelOperationException operationException) { + return CompletableFuture.failedFuture(operationException); + } + return CompletableFuture.failedFuture(new ParcelOperationException( + "Failed to collect parcel " + parcel.uuid(), cause)); + }); + } + + private CompletableFuture collectAuthoritative(Player player, Parcel parcel) { + UUID playerId = player.getUniqueId(); return this.fireCollectEvent(parcel).thenCompose(cancelled -> { if (cancelled) { - this.noticeService.player( - player.getUniqueId(), messages -> messages.parcel.cannotCollect); + this.notifyCannotCollect(playerId); return CompletableFuture.completedFuture(null); } return this.parcelContentRepository.find(parcel.uuid()).thenCompose(optional -> { if (optional.isEmpty()) { - this.noticeService.player( - player.getUniqueId(), messages -> messages.parcel.cannotCollect); + this.notifyCannotCollect(playerId); return CompletableFuture.completedFuture(null); } List items = optional.get().items(); CompletableFuture result = new CompletableFuture<>(); + this.scheduleCollection(player, parcel, items, result); + return result; + }); + }); + } - // Re-check inventory space on the main thread (the previous async check was a TOCTOU), - // 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(() -> { + private void scheduleCollection( + Player player, + Parcel parcel, + List items, + CompletableFuture result + ) { + UUID playerId = player.getUniqueId(); + try { + this.scheduler.run(() -> { + try { if (!canHold(player, items)) { - this.noticeService.player( - player.getUniqueId(), - messages -> messages.parcel.noInventorySpace - ); + this.notifyNoInventorySpace(playerId); result.complete(null); return; } - this.collectedParcelRepository.save( - new CollectedParcel(parcel.uuid(), Instant.now())) - .thenCompose(saved -> - this.parcelRepository.markCollected(parcel.uuid())) - .thenAccept(marked -> { + Instant collectedAt = Instant.now(); + this.parcelRepository.commitCollection( + parcel.uuid(), playerId, collectedAt) + .whenComplete((marked, throwable) -> { + if (throwable != null) { + this.notifyCannotCollect(playerId); + result.completeExceptionally(new ParcelOperationException( + "Failed to persist collection for parcel " + parcel.uuid(), + unwrap(throwable))); + return; + } 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 - ); + this.notifyCannotCollect(playerId); result.complete(null); return; } 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 - ); - }); - result.complete(null); - }) - .exceptionally(throwable -> { - this.noticeService.player( - player.getUniqueId(), - messages -> messages.parcel.cannotCollect - ); - result.complete(null); - return null; + parcel.uuid(), withStatus(parcel, ParcelStatus.COLLECTED)); + this.scheduleCollectedItems( + player, parcel, items, collectedAt, result); }); - }); + } catch (Throwable throwable) { + result.completeExceptionally(new ParcelOperationException( + "Failed to collect parcel " + parcel.uuid(), unwrap(throwable))); + } + }); + } catch (Throwable throwable) { + result.completeExceptionally(new ParcelOperationException( + "Failed to schedule collection for parcel " + parcel.uuid(), unwrap(throwable))); + } + } - return result; + private void scheduleCollectedItems( + Player player, + Parcel parcel, + List items, + Instant collectedAt, + CompletableFuture result + ) { + try { + this.scheduler.run(() -> { + ItemStack[] inventorySnapshot = null; + try { + inventorySnapshot = Arrays.stream(player.getInventory().getContents()) + .map(item -> item == null ? null : item.clone()) + .toArray(ItemStack[]::new); + items.forEach(item -> ItemUtil.giveItem(player, item)); + this.notifyCollected(player.getUniqueId()); + result.complete(null); + } catch (Throwable throwable) { + this.failCollectionDelivery( + player, parcel, collectedAt, inventorySnapshot, + unwrap(throwable), result); + } }); + } catch (Throwable throwable) { + this.failCollectionDelivery( + player, parcel, collectedAt, null, unwrap(throwable), result); + } + } + + private void failCollectionDelivery( + Player player, + Parcel parcel, + Instant collectedAt, + ItemStack[] inventorySnapshot, + Throwable trigger, + CompletableFuture result + ) { + ParcelOperationException failure = new ParcelOperationException( + "Failed to give collected parcel items " + parcel.uuid(), trigger); + if (inventorySnapshot != null) { + try { + player.getInventory().setContents(inventorySnapshot); + } catch (Throwable restoreFailure) { + failure.addSuppressed(unwrap(restoreFailure)); + this.parcelsByUuid.invalidate(parcel.uuid()); + result.completeExceptionally(failure); + return; + } + } + + CompletableFuture rollback; + try { + rollback = this.parcelRepository.rollbackCollection( + parcel.uuid(), player.getUniqueId(), collectedAt); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(unwrap(rollbackFailure)); + this.parcelsByUuid.invalidate(parcel.uuid()); + result.completeExceptionally(failure); + return; + } + rollback.whenComplete((rolledBack, rollbackFailure) -> { + if (rollbackFailure != null) { + failure.addSuppressed(unwrap(rollbackFailure)); + } else if (!Boolean.TRUE.equals(rolledBack)) { + failure.addSuppressed(new IllegalStateException( + "Collection rollback returned false")); + } + this.parcelsByUuid.invalidate(parcel.uuid()); + result.completeExceptionally(failure); }); } + private void notifyCannotCollect(UUID player) { + try { + this.noticeService.player(player, messages -> messages.parcel.cannotCollect); + } catch (Throwable ignored) { + // Notices are best-effort and must never leave an operation future incomplete. + } + } + + private void notifyNoInventorySpace(UUID player) { + try { + this.noticeService.player(player, messages -> messages.parcel.noInventorySpace); + } catch (Throwable ignored) { + // Notices are best-effort and must never leave an operation future incomplete. + } + } + + private void notifyCollected(UUID player) { + try { + this.noticeService.player(player, messages -> messages.parcel.collected); + } catch (Throwable ignored) { + // Collection is already committed; notification failure is non-transactional. + } + } + private CompletableFuture fireCollectEvent(Parcel parcel) { if (this.server.isPrimaryThread()) { try { diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java index db041874..a367786a 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java @@ -10,7 +10,7 @@ @DatabaseTable(tableName = "collected_parcels") public class CollectedParcelTable { - static final String COLLECTED_AT_COLUMN = "collected_at"; + public static final String COLLECTED_AT_COLUMN = "collected_at"; @DatabaseField(id = true) private UUID parcel; @@ -26,7 +26,7 @@ public class CollectedParcelTable { this.collectedAt = collectedAt; } - static CollectedParcelTable from(CollectedParcel collectedParcel) { + public static CollectedParcelTable from(CollectedParcel collectedParcel) { return new CollectedParcelTable(collectedParcel.parcel(), collectedParcel.collectedAt()); } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java index 5c9b0450..8dab0e0e 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -1,19 +1,28 @@ package com.eternalcode.parcellockers.parcel.service; +import static com.eternalcode.parcellockers.util.InventoryUtil.canHold; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +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.doAnswer; import static org.mockito.Mockito.doThrow; 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.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; import com.eternalcode.parcellockers.content.repository.ParcelContentRepository; import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; @@ -23,19 +32,326 @@ import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import com.eternalcode.parcellockers.shared.exception.ValidationException; +import java.util.ArrayDeque; import java.util.List; +import java.util.Optional; +import java.util.Queue; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Server; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; class ParcelServiceImplTest { + @Test + void collectRejectsForgedReceiverFromAuthoritativeRecordWithoutSideEffects() { + CollectFixture fixture = new CollectFixture(); + Parcel forged = withReceiver(fixture.authoritative, fixture.playerId); + when(fixture.parcelRepository.findById(forged.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(fixture.authoritative))); + + CompletionException exception = assertThrows( + CompletionException.class, () -> fixture.service.collect(fixture.player, forged).join()); + + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(fixture.pluginManager, never()).callEvent(any()); + verify(fixture.contentRepository, never()).find(any()); + verify(fixture.parcelRepository, never()).markCollected(any()); + } + + @Test + void collectRejectsStaleDeliveredDtoWhenAuthoritativeRecordIsNotDelivered() { + CollectFixture fixture = new CollectFixture(); + Parcel caller = withReceiver(fixture.authoritative, fixture.playerId); + Parcel collected = withStatus(caller, ParcelStatus.COLLECTED); + when(fixture.parcelRepository.findById(caller.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(collected))); + + CompletionException exception = assertThrows( + CompletionException.class, () -> fixture.service.collect(fixture.player, caller).join()); + + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(fixture.pluginManager, never()).callEvent(any()); + verify(fixture.contentRepository, never()).find(any()); + verify(fixture.parcelRepository, never()).markCollected(any()); + } + + @Test + void collectPersistenceFailureCompletesExceptionallyEvenWhenFailureNoticeThrows() { + CollectFixture fixture = new CollectFixture(); + Parcel parcel = withReceiver(fixture.authoritative, fixture.playerId); + IllegalStateException databaseFailure = new IllegalStateException("database failed"); + fixture.stubCollect(parcel); + when(fixture.parcelRepository.commitCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.failedFuture(databaseFailure)); + doThrow(new IllegalStateException("notice failed")) + .when(fixture.noticeService).player(eq(fixture.playerId), any()); + + try (MockedStatic inventory = + mockStatic(com.eternalcode.parcellockers.util.InventoryUtil.class)) { + inventory.when(() -> canHold(fixture.player, List.of())).thenReturn(true); + CompletableFuture result = fixture.service.collect(fixture.player, parcel); + fixture.runNextMainTask(); + + CompletionException exception = assertThrows(CompletionException.class, result::join); + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(databaseFailure, operationException.getCause()); + } + } + + @Test + void collectFutureCompletesOnlyAfterMainThreadItemsAndNotice() { + CollectFixture fixture = new CollectFixture(); + Parcel parcel = withReceiver(fixture.authoritative, fixture.playerId); + ItemStack item = mock(ItemStack.class); + fixture.stubCollect(parcel, item); + when(fixture.parcelRepository.commitCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + try (MockedStatic inventory = + mockStatic(com.eternalcode.parcellockers.util.InventoryUtil.class); + MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + inventory.when(() -> canHold(fixture.player, List.of(item))).thenReturn(true); + + CompletableFuture result = fixture.service.collect(fixture.player, parcel); + + assertFalse(result.isDone()); + fixture.runNextMainTask(); + assertFalse(result.isDone()); + fixture.runNextMainTask(); + itemUtil.verify(() -> ItemUtil.giveItem(fixture.player, item)); + verify(fixture.noticeService).player( + eq(fixture.playerId), any()); + verify(fixture.collectedRepository, never()).save(any()); + assertTrue(result.isDone()); + result.join(); + } + } + + @Test + void collectSchedulerFailureRollsBackCommittedCollection() { + CollectFixture fixture = new CollectFixture(); + Parcel parcel = withReceiver(fixture.authoritative, fixture.playerId); + fixture.stubCollect(parcel); + when(fixture.parcelRepository.commitCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.parcelRepository.rollbackCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + AtomicInteger scheduled = new AtomicInteger(); + doAnswer(invocation -> { + if (scheduled.getAndIncrement() == 0) { + fixture.mainTasks.add(invocation.getArgument(0)); + return null; + } + throw new IllegalStateException("scheduler rejected delivery"); + }).when(fixture.scheduler).run(any(Runnable.class)); + + try (MockedStatic inventory = + mockStatic(com.eternalcode.parcellockers.util.InventoryUtil.class)) { + inventory.when(() -> canHold(fixture.player, List.of())).thenReturn(true); + CompletableFuture result = fixture.service.collect(fixture.player, parcel); + fixture.runNextMainTask(); + + assertThrows(CompletionException.class, result::join); + verify(fixture.parcelRepository) + .rollbackCollection(eq(parcel.uuid()), eq(fixture.playerId), any()); + } + } + + @Test + void collectGiveFailureRestoresInventoryAndRollsBackCollection() { + CollectFixture fixture = new CollectFixture(); + Parcel parcel = withReceiver(fixture.authoritative, fixture.playerId); + ItemStack item = mock(ItemStack.class); + ItemStack existing = mock(ItemStack.class); + ItemStack existingSnapshot = mock(ItemStack.class); + PlayerInventory playerInventory = mock(PlayerInventory.class); + ItemStack[] snapshot = {existing}; + fixture.stubCollect(parcel, item); + when(fixture.player.getInventory()).thenReturn(playerInventory); + when(playerInventory.getContents()).thenReturn(snapshot); + when(existing.clone()).thenReturn(existingSnapshot); + when(fixture.parcelRepository.commitCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.parcelRepository.rollbackCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + try (MockedStatic inventory = + mockStatic(com.eternalcode.parcellockers.util.InventoryUtil.class); + MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + inventory.when(() -> canHold(fixture.player, List.of(item))).thenReturn(true); + itemUtil.when(() -> ItemUtil.giveItem(fixture.player, item)) + .thenThrow(new IllegalStateException("give failed")); + CompletableFuture result = fixture.service.collect(fixture.player, parcel); + fixture.runNextMainTask(); + fixture.runNextMainTask(); + + assertThrows(CompletionException.class, result::join); + ArgumentCaptor restored = + ArgumentCaptor.forClass(ItemStack[].class); + verify(playerInventory).setContents(restored.capture()); + assertSame(existingSnapshot, restored.getValue()[0]); + verify(fixture.parcelRepository) + .rollbackCollection(eq(parcel.uuid()), eq(fixture.playerId), any()); + } + } + + @Test + void collectDoesNotReopenParcelWhenInventoryRestoreFails() { + CollectFixture fixture = new CollectFixture(); + Parcel parcel = withReceiver(fixture.authoritative, fixture.playerId); + ItemStack item = mock(ItemStack.class); + ItemStack existing = mock(ItemStack.class); + PlayerInventory playerInventory = mock(PlayerInventory.class); + fixture.stubCollect(parcel, item); + when(fixture.player.getInventory()).thenReturn(playerInventory); + when(playerInventory.getContents()).thenReturn(new ItemStack[]{existing}); + when(existing.clone()).thenReturn(mock(ItemStack.class)); + when(fixture.parcelRepository.commitCollection( + eq(parcel.uuid()), eq(fixture.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + doThrow(new IllegalStateException("restore failed")) + .when(playerInventory).setContents(any(ItemStack[].class)); + + try (MockedStatic inventory = + mockStatic(com.eternalcode.parcellockers.util.InventoryUtil.class); + MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + inventory.when(() -> canHold(fixture.player, List.of(item))).thenReturn(true); + itemUtil.when(() -> ItemUtil.giveItem(fixture.player, item)) + .thenThrow(new IllegalStateException("give failed")); + CompletableFuture result = fixture.service.collect(fixture.player, parcel); + fixture.runNextMainTask(); + fixture.runNextMainTask(); + + assertThrows(CompletionException.class, result::join); + verify(fixture.parcelRepository, never()) + .rollbackCollection(any(), any(), any()); + } + } + + @Test + void duplicateParcelUuidFailsBeforeChargeAndDoesNotDeleteExistingParcel() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + config.settings.smallParcelFee = 10.0; + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + Player sender = mock(Player.class); + Parcel parcel = parcel(); + when(server.getPluginManager()).thenReturn(pluginManager); + when(sender.hasPermission("parcellockers.fee.bypass")).thenReturn(false); + when(parcelRepository.saveIfAbsent(parcel)) + .thenReturn(CompletableFuture.completedFuture(false)); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, parcelRepository, contentRepository, collectedRepository, + scheduler, config, economy, server); + + CompletionException exception = assertThrows(CompletionException.class, + () -> service.send(sender, parcel, List.of(mock(ItemStack.class))).join()); + + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + verify(contentRepository, never()).save(any()); + verify(parcelRepository, never()).delete(parcel.uuid()); + verify(contentRepository, never()).delete(parcel.uuid()); + } + + @Test + void feeNoticeFailureAfterWithdrawalRefundsAndCleansReservedParcel() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + config.settings.smallParcelFee = 10.0; + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + PluginManager pluginManager = mock(PluginManager.class); + Player sender = mock(Player.class); + Parcel parcel = parcel(); + when(server.getPluginManager()).thenReturn(pluginManager); + when(sender.hasPermission("parcellockers.fee.bypass")).thenReturn(false); + when(parcelRepository.saveIfAbsent(parcel)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(economy.withdrawPlayer(sender, 10.0)) + .thenReturn(successfulEconomyResponse(10.0)); + when(economy.depositPlayer(sender, 10.0)) + .thenReturn(successfulEconomyResponse(10.0)); + when(noticeService.create()).thenThrow(new IllegalStateException("notice failed")); + when(parcelRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(contentRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, parcelRepository, contentRepository, collectedRepository, + scheduler, config, economy, server); + + CompletionException exception = assertThrows(CompletionException.class, + () -> service.send(sender, parcel, List.of(mock(ItemStack.class))).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + verify(economy).depositPlayer(sender, 10.0); + verify(parcelRepository).delete(parcel.uuid()); + verify(contentRepository).delete(parcel.uuid()); + } + + @Test + void rollbackPermissionFailureDoesNotSkipParcelAndContentCleanup() { + NoticeService noticeService = mock(NoticeService.class); + ParcelRepository parcelRepository = mock(ParcelRepository.class); + ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + CollectedParcelRepository collectedRepository = mock(CollectedParcelRepository.class); + Scheduler scheduler = mock(Scheduler.class); + PluginConfig config = new PluginConfig(); + Economy economy = mock(Economy.class); + Server server = mock(Server.class); + Player sender = mock(Player.class); + Parcel parcel = parcel(); + IllegalStateException permissionFailure = new IllegalStateException("permission failed"); + when(sender.hasPermission("parcellockers.fee.bypass")).thenThrow(permissionFailure); + when(parcelRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(contentRepository.delete(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + ParcelServiceImpl service = new ParcelServiceImpl( + noticeService, parcelRepository, contentRepository, collectedRepository, + scheduler, config, economy, server); + + CompletableFuture result = + assertDoesNotThrow(() -> service.rollbackSend(sender, parcel)); + CompletionException exception = assertThrows(CompletionException.class, result::join); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(permissionFailure, operationException.getCause()); + verify(parcelRepository).delete(parcel.uuid()); + verify(contentRepository).delete(parcel.uuid()); + } + @Test void collectDefersSynchronousEventToMainSchedulerWhenCalledAsync() { NoticeService noticeService = mock(NoticeService.class); @@ -63,6 +379,9 @@ void collectDefersSynchronousEventToMainSchedulerWhenCalledAsync() { ); when(server.isPrimaryThread()).thenReturn(false); when(server.getPluginManager()).thenReturn(pluginManager); + when(player.getUniqueId()).thenReturn(parcel.receiver()); + when(parcelRepository.findById(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(parcel))); when(contentRepository.find(parcel.uuid())) .thenReturn(CompletableFuture.completedFuture(java.util.Optional.empty())); ParcelServiceImpl service = new ParcelServiceImpl( @@ -108,7 +427,7 @@ void contentPersistenceFailureAttemptsParcelAndContentCleanupAndPreservesTrigger new IllegalStateException("content delete failed"); when(sender.hasPermission("parcellockers.fee.bypass")).thenReturn(true); when(server.getPluginManager()).thenReturn(pluginManager); - when(parcelRepository.save(parcel)).thenReturn(CompletableFuture.completedFuture(null)); + when(parcelRepository.saveIfAbsent(parcel)).thenReturn(CompletableFuture.completedFuture(true)); when(contentRepository.save(any())) .thenReturn(CompletableFuture.failedFuture(contentSaveFailure)); when(parcelRepository.delete(parcel.uuid())) @@ -250,4 +569,72 @@ private static Parcel parcel() { ParcelStatus.SENT ); } + + private static Parcel withReceiver(Parcel parcel, UUID receiver) { + return new Parcel(parcel.uuid(), parcel.sender(), parcel.name(), parcel.description(), + parcel.priority(), receiver, parcel.size(), parcel.entryLocker(), + parcel.destinationLocker(), ParcelStatus.DELIVERED); + } + + 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); + } + + private static EconomyResponse successfulEconomyResponse(double amount) { + return new EconomyResponse( + amount, 100.0, EconomyResponse.ResponseType.SUCCESS, null); + } + + private static final class CollectFixture { + + private final NoticeService noticeService = mock(NoticeService.class); + private final ParcelRepository parcelRepository = mock(ParcelRepository.class); + private final ParcelContentRepository contentRepository = + mock(ParcelContentRepository.class); + private final CollectedParcelRepository collectedRepository = + mock(CollectedParcelRepository.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final PluginConfig config = new PluginConfig(); + private final Economy economy = mock(Economy.class); + private final Server server = mock(Server.class); + private final PluginManager pluginManager = mock(PluginManager.class); + private final Player player = mock(Player.class); + private final PlayerInventory playerInventory = mock(PlayerInventory.class); + private final UUID playerId = UUID.randomUUID(); + private final Queue mainTasks = new ArrayDeque<>(); + private final Parcel authoritative = new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "name", null, false, UUID.randomUUID(), + ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.DELIVERED); + private final ParcelServiceImpl service; + + private CollectFixture() { + when(this.player.getUniqueId()).thenReturn(this.playerId); + when(this.player.getInventory()).thenReturn(this.playerInventory); + when(this.playerInventory.getContents()).thenReturn(new ItemStack[0]); + when(this.server.isPrimaryThread()).thenReturn(true); + when(this.server.getPluginManager()).thenReturn(this.pluginManager); + doAnswer(invocation -> { + this.mainTasks.add(invocation.getArgument(0)); + return null; + }).when(this.scheduler).run(any(Runnable.class)); + this.service = new ParcelServiceImpl( + this.noticeService, this.parcelRepository, this.contentRepository, + this.collectedRepository, this.scheduler, this.config, this.economy, this.server); + } + + private void stubCollect(Parcel parcel, ItemStack... items) { + when(this.parcelRepository.findById(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(parcel))); + ParcelContent content = new ParcelContent(parcel.uuid(), List.of(items)); + when(this.contentRepository.find(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(content))); + } + + private void runNextMainTask() { + Runnable task = this.mainTasks.remove(); + task.run(); + } + } } From b2588c2af91c019d13cf8da363cf68f09c6edd61 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:24:07 +0200 Subject: [PATCH 17/19] fix: serialize parcel lifecycle mutations --- .../parcel/service/AdminParcelService.java | 4 +- .../parcel/service/ParcelDispatchService.java | 19 +- .../parcel/service/ParcelServiceImpl.java | 203 +++++++++++++++++- .../parcel/service/PluginParcelService.java | 23 ++ .../parcel/task/ParcelSendTask.java | 56 ++--- .../service/ParcelDispatchServiceTest.java | 67 +++--- .../parcel/service/ParcelServiceImplTest.java | 194 +++++++++++++++++ .../parcel/task/ParcelSendTaskTest.java | 54 +++++ .../CollectedParcelTableMappingTest.java | 19 ++ 9 files changed, 575 insertions(+), 64 deletions(-) create mode 100644 parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTableMappingTest.java diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index 01547f50..d3546c99 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -16,14 +16,14 @@ public class AdminParcelService { - private final ParcelService parcelService; + private final PluginParcelService parcelService; private final ParcelContentManager parcelContentManager; private final DeliveryManager deliveryManager; private final LockerManager lockerManager; private final PluginConfig config; private final Scheduler scheduler; - public AdminParcelService(ParcelService parcelService, ParcelContentManager parcelContentManager, + public AdminParcelService(PluginParcelService parcelService, ParcelContentManager parcelContentManager, DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config, Scheduler scheduler) { this.parcelService = parcelService; this.parcelContentManager = parcelContentManager; diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java index 8ea277e7..6ee04689 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -59,6 +59,21 @@ public ParcelDispatchService( } public CompletableFuture dispatch(Player sender, Parcel parcel, List items) { + try { + return this.parcelService.serializeParcelOperation( + parcel.uuid(), () -> this.dispatchWithinParcelOperation(sender, parcel, items)); + } catch (Throwable throwable) { + this.notifyCannotSend(sender); + return CompletableFuture.failedFuture(this.operationFailure( + "Failed to serialize dispatch for parcel " + parcel.uuid(), throwable)); + } + } + + private CompletableFuture dispatchWithinParcelOperation( + Player sender, + Parcel parcel, + List items + ) { ItemStorageReservation reservation = this.itemStorageManager .reserve(sender.getUniqueId()) .orElse(null); @@ -109,7 +124,7 @@ private CompletableFuture dispatchInternal( ? this.config.settings.priorityParcelSendDuration : this.config.settings.parcelSendDuration; - return this.parcelService.send(sender, parcel, items) + return this.parcelService.sendWithinParcelOperation(sender, parcel, items) .thenCompose(success -> { if (!Boolean.TRUE.equals(success)) { this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); @@ -311,7 +326,7 @@ private CleanupStep restoreStep( private CleanupStep rollbackStep(Player sender, Parcel parcel) { return new CleanupStep( "Roll back parcel " + parcel.uuid(), - () -> this.parcelService.rollbackSend(sender, parcel) + () -> this.parcelService.rollbackSendWithinParcelOperation(sender, parcel) ); } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index be0cd199..dcaf9510 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -25,12 +25,15 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import net.milkbowl.vault.economy.Economy; import org.bukkit.Server; import org.bukkit.command.CommandSender; @@ -56,6 +59,10 @@ public class ParcelServiceImpl implements PluginParcelService { private final Server server; private final Cache parcelsByUuid; + private final Map> parcelOperationTails = new HashMap<>(); + private CompletableFuture globalParcelOperationTail = + CompletableFuture.completedFuture(null); + private final ThreadLocal parcelOperationCallback = new ThreadLocal<>(); public ParcelServiceImpl( NoticeService noticeService, @@ -84,14 +91,26 @@ public ParcelServiceImpl( @Override public CompletableFuture send(Player sender, Parcel parcel, List items) { + Objects.requireNonNull(parcel, "Parcel cannot be null"); + return this.serializeParcelOperation( + parcel.uuid(), () -> this.sendWithinParcelOperation(sender, parcel, items)); + } + + @Override + public CompletableFuture sendWithinParcelOperation( + Player sender, + Parcel parcel, + List items + ) { Objects.requireNonNull(sender, "Sender cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); Objects.requireNonNull(items, "Items list cannot be null"); Preconditions.checkArgument(!items.isEmpty(), "Items list cannot be empty"); ParcelSendEvent event = new ParcelSendEvent(parcel); - this.server.getPluginManager().callEvent(event); - + this.runParcelOperationCallback( + parcel.uuid(), () -> this.server.getPluginManager().callEvent(event)); + if (event.isCancelled()) { this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); return CompletableFuture.completedFuture(false); @@ -226,6 +245,16 @@ private CompletableFuture compensatePersistenceFailure( @Override public CompletableFuture rollbackSend(Player sender, Parcel parcel) { + Objects.requireNonNull(parcel, "Parcel cannot be null"); + return this.serializeParcelOperation( + parcel.uuid(), () -> this.rollbackSendWithinParcelOperation(sender, parcel)); + } + + @Override + public CompletableFuture rollbackSendWithinParcelOperation( + Player sender, + Parcel parcel + ) { Objects.requireNonNull(sender, "Sender cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); @@ -330,6 +359,12 @@ private void notifyCannotSend(Player sender) { @Override public CompletableFuture update(Parcel updated) { Objects.requireNonNull(updated, "Updated parcel cannot be null"); + return this.serializeParcelOperation( + updated.uuid(), () -> this.updateWithinParcelOperation(updated)); + } + + @Override + public CompletableFuture updateWithinParcelOperation(Parcel updated) { this.parcelsByUuid.put(updated.uuid(), updated); return this.parcelRepository.update(updated); } @@ -338,6 +373,14 @@ public CompletableFuture update(Parcel updated) { 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.serializeParcelOperation( + updated.uuid(), () -> this.updateIfStatusWithinOperation(updated, expectedStatus)); + } + + private CompletableFuture updateIfStatusWithinOperation( + Parcel updated, + ParcelStatus expectedStatus + ) { return this.parcelRepository.updateIfStatus(updated, expectedStatus).thenApply(applied -> { if (applied) { this.parcelsByUuid.put(updated.uuid(), updated); @@ -352,7 +395,11 @@ public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus ex public CompletableFuture delete(CommandSender sender, Parcel parcel) { Objects.requireNonNull(sender, "Sender cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); + return this.serializeParcelOperation( + parcel.uuid(), () -> this.deleteWithinOperation(sender, parcel)); + } + private CompletableFuture deleteWithinOperation(CommandSender sender, Parcel parcel) { return this.parcelRepository.delete(parcel) .thenAccept(unused -> { this.noticeService.create() @@ -374,7 +421,11 @@ public CompletableFuture delete(CommandSender sender, Parcel parcel) { public CompletableFuture collect(Player player, Parcel parcel) { Objects.requireNonNull(player, "Player cannot be null"); Objects.requireNonNull(parcel, "Parcel cannot be null"); + return this.serializeParcelOperation( + parcel.uuid(), () -> this.collectWithinOperation(player, parcel)); + } + private CompletableFuture collectWithinOperation(Player player, Parcel parcel) { UUID playerId = player.getUniqueId(); CompletableFuture> authoritativeFuture; try { @@ -577,7 +628,8 @@ private CompletableFuture fireCollectEvent(Parcel parcel) { if (this.server.isPrimaryThread()) { try { ParcelCollectEvent event = new ParcelCollectEvent(parcel); - this.server.getPluginManager().callEvent(event); + this.runParcelOperationCallback( + parcel.uuid(), () -> this.server.getPluginManager().callEvent(event)); return CompletableFuture.completedFuture(event.isCancelled()); } catch (Throwable throwable) { return CompletableFuture.failedFuture(throwable); @@ -589,7 +641,8 @@ private CompletableFuture fireCollectEvent(Parcel parcel) { this.scheduler.run(() -> { try { ParcelCollectEvent event = new ParcelCollectEvent(parcel); - this.server.getPluginManager().callEvent(event); + this.runParcelOperationCallback( + parcel.uuid(), () -> this.server.getPluginManager().callEvent(event)); result.complete(event.isCancelled()); } catch (Throwable throwable) { result.completeExceptionally(throwable); @@ -688,6 +741,23 @@ public CompletableFuture> getAll(Page page) { @Override public CompletableFuture delete(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); + return this.serializeParcelOperation(uuid, () -> this.deleteWithinOperation(uuid)); + } + + @Override + public CompletableFuture> getAuthoritativeWithinParcelOperation(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + return this.parcelRepository.findById(uuid).thenApply(optional -> { + if (optional.isPresent()) { + this.parcelsByUuid.put(uuid, optional.get()); + } else { + this.parcelsByUuid.invalidate(uuid); + } + return optional; + }); + } + + private CompletableFuture deleteWithinOperation(UUID uuid) { return this.parcelRepository.delete(uuid).thenCompose(deleted -> { if (!deleted) { return CompletableFuture.completedFuture(false); @@ -712,11 +782,136 @@ public CompletableFuture delete(Parcel parcel) { return this.delete(parcel.uuid()); } + @Override + public CompletableFuture serializeParcelOperation( + UUID parcel, + Supplier> operation + ) { + Objects.requireNonNull(parcel, "Parcel UUID cannot be null"); + Objects.requireNonNull(operation, "Parcel operation cannot be null"); + if (this.parcelOperationCallback.get() != null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Parcel mutations cannot be started from a parcel event callback")); + } + + CompletableFuture gate = new CompletableFuture<>(); + CompletableFuture predecessor; + CompletableFuture globalPredecessor; + synchronized (this.parcelOperationTails) { + predecessor = this.parcelOperationTails.put(parcel, gate); + globalPredecessor = this.globalParcelOperationTail; + } + if (predecessor == null) { + predecessor = CompletableFuture.completedFuture(null); + } + + CompletableFuture ready = CompletableFuture.allOf( + predecessor.handle((ignored, throwable) -> null), + globalPredecessor.handle((ignored, throwable) -> null)); + CompletableFuture result = new CompletableFuture<>(); + ready.thenRun(() -> { + CompletableFuture running; + try { + running = Objects.requireNonNull( + operation.get(), "Parcel operation returned a null future"); + } catch (Throwable throwable) { + gate.complete(null); + result.completeExceptionally(throwable); + return; + } + running.whenComplete((value, throwable) -> { + gate.complete(null); + if (throwable != null) { + result.completeExceptionally(throwable); + } else { + result.complete(value); + } + }); + }); + gate.whenComplete((ignored, throwable) -> { + synchronized (this.parcelOperationTails) { + this.parcelOperationTails.remove(parcel, gate); + } + }); + return result; + } + + @Override + public void runParcelOperationCallback(UUID parcel, Runnable callback) { + Objects.requireNonNull(parcel, "Parcel UUID cannot be null"); + Objects.requireNonNull(callback, "Parcel callback cannot be null"); + UUID previous = this.parcelOperationCallback.get(); + this.parcelOperationCallback.set(parcel); + try { + callback.run(); + } finally { + if (previous == null) { + this.parcelOperationCallback.remove(); + } else { + this.parcelOperationCallback.set(previous); + } + } + } + + private CompletableFuture serializeAllParcelOperations( + Supplier> operation + ) { + if (this.parcelOperationCallback.get() != null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Bulk parcel mutations cannot be started from a parcel event callback")); + } + CompletableFuture gate = new CompletableFuture<>(); + CompletableFuture ready; + synchronized (this.parcelOperationTails) { + List> predecessors = + new ArrayList<>(this.parcelOperationTails.values()); + predecessors.add(this.globalParcelOperationTail); + ready = CompletableFuture.allOf(predecessors.toArray(CompletableFuture[]::new)); + this.globalParcelOperationTail = gate; + } + + CompletableFuture result = new CompletableFuture<>(); + ready.handle((ignored, throwable) -> null).thenRun(() -> { + CompletableFuture running; + try { + running = Objects.requireNonNull( + operation.get(), "Global parcel operation returned a null future"); + } catch (Throwable throwable) { + gate.complete(null); + result.completeExceptionally(throwable); + return; + } + running.whenComplete((value, throwable) -> { + gate.complete(null); + if (throwable != null) { + result.completeExceptionally(throwable); + } else { + result.complete(value); + } + }); + }); + gate.whenComplete((ignored, throwable) -> { + synchronized (this.parcelOperationTails) { + if (this.globalParcelOperationTail == gate) { + this.globalParcelOperationTail = CompletableFuture.completedFuture(null); + } + } + }); + return result; + } + @Override public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { Objects.requireNonNull(sender, "Sender cannot be null"); Objects.requireNonNull(noticeService, "NoticeService cannot be null"); + return this.serializeAllParcelOperations( + () -> this.deleteAllWithinOperation(sender, noticeService)); + } + private CompletableFuture deleteAllWithinOperation( + CommandSender sender, + NoticeService noticeService + ) { return this.parcelRepository.deleteAll().thenCompose(deleted -> { noticeService.create() .notice(messages -> messages.admin.deletedParcels) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java index ef3440b8..d17311fb 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java @@ -2,15 +2,38 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; +import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; public interface PluginParcelService extends ParcelService { CompletableFuture rollbackSend(Player sender, Parcel parcel); + CompletableFuture sendWithinParcelOperation( + Player sender, + Parcel parcel, + List items + ); + + CompletableFuture rollbackSendWithinParcelOperation(Player sender, Parcel parcel); + + CompletableFuture serializeParcelOperation( + UUID parcel, + Supplier> operation + ); + + CompletableFuture updateWithinParcelOperation(Parcel parcel); + + CompletableFuture> getAuthoritativeWithinParcelOperation(UUID parcel); + + void runParcelOperationCallback(UUID parcel, Runnable callback); + void invalidate(UUID uuid); CompletableFuture delete(CommandSender sender, Parcel parcel); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index d06f9d75..5d666475 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -6,7 +6,7 @@ import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.event.ParcelDeliverEvent; -import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import java.time.Duration; import java.time.Instant; import java.util.Optional; @@ -23,11 +23,11 @@ public class ParcelSendTask extends BukkitRunnable { public enum Decision { DELIVER, RESCHEDULE, ABORT } private final UUID parcelId; - private final ParcelService parcelService; + private final PluginParcelService parcelService; private final DeliveryManager deliveryManager; private final Scheduler scheduler; - public ParcelSendTask(Parcel parcel, ParcelService parcelService, DeliveryManager deliveryManager, Scheduler scheduler) { + public ParcelSendTask(Parcel parcel, PluginParcelService parcelService, DeliveryManager deliveryManager, Scheduler scheduler) { this.parcelId = parcel.uuid(); this.parcelService = parcelService; this.deliveryManager = deliveryManager; @@ -47,50 +47,54 @@ public static Decision decide(Optional currentParcel, Optional @Override public void run() { - this.parcelService.get(this.parcelId).thenCompose(optionalParcel -> - this.deliveryManager.get(this.parcelId).thenAccept(optionalDelivery -> { + this.parcelService.serializeParcelOperation(this.parcelId, this::runWithinParcelOperation) + .exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "ParcelSendTask failed for " + this.parcelId, throwable); + return null; + }); + } + + private java.util.concurrent.CompletableFuture runWithinParcelOperation() { + return this.parcelService.getAuthoritativeWithinParcelOperation(this.parcelId) + .thenCompose(optionalParcel -> + this.deliveryManager.get(this.parcelId).thenCompose(optionalDelivery -> { Instant now = Instant.now(); - switch (decide(optionalParcel, optionalDelivery, now)) { - case ABORT -> + return switch (decide(optionalParcel, optionalDelivery, now)) { + case ABORT -> { // Parcel gone or already delivered: clean up any stray delivery row. - optionalDelivery.ifPresent(delivery -> - this.deliveryManager.delete(this.parcelId).exceptionally(throwable -> { - LOGGER.log(Level.SEVERE, "Failed to delete stray delivery for " + this.parcelId, throwable); - return false; - })); + if (optionalDelivery.isEmpty()) { + yield java.util.concurrent.CompletableFuture.completedFuture(null); + } + yield this.deliveryManager.delete(this.parcelId).thenAccept(deleted -> {}); + } case RESCHEDULE -> { Duration remaining = Duration.between(now, optionalDelivery.get().deliveryTimestamp()); // Reschedule a fresh task; this instance ends after this run. this.scheduler.runLaterAsync( new ParcelSendTask(optionalParcel.get(), this.parcelService, this.deliveryManager, this.scheduler), remaining.isNegative() ? Duration.ZERO : remaining); + yield java.util.concurrent.CompletableFuture.completedFuture(null); } case DELIVER -> this.deliver(optionalParcel.get()); - } - })).exceptionally(throwable -> { - LOGGER.log(Level.SEVERE, "ParcelSendTask failed for " + this.parcelId, throwable); - return null; - }); + }; + })); } - private void deliver(Parcel current) { + private java.util.concurrent.CompletableFuture deliver(Parcel current) { Parcel delivered = new Parcel(current.uuid(), current.sender(), current.name(), current.description(), current.priority(), current.receiver(), current.size(), current.entryLocker(), current.destinationLocker(), ParcelStatus.DELIVERED); ParcelDeliverEvent event = new ParcelDeliverEvent(delivered); - Bukkit.getPluginManager().callEvent(event); + this.parcelService.runParcelOperationCallback( + this.parcelId, () -> Bukkit.getPluginManager().callEvent(event)); if (event.isCancelled()) { LOGGER.info("ParcelDeliverEvent was cancelled for parcel " + delivered.uuid()); - return; + return java.util.concurrent.CompletableFuture.completedFuture(null); } - this.parcelService.update(delivered) + return this.parcelService.updateWithinParcelOperation(delivered) .thenCompose(ignored -> this.deliveryManager.delete(delivered.uuid())) - .exceptionally(throwable -> { - LOGGER.log(Level.SEVERE, "Failed to deliver parcel " + delivered.uuid() - + " (delivery left for retry)", throwable); - return null; - }); + .thenAccept(deleted -> {}); } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java index 05c2da3b..ea11db11 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -36,6 +36,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.function.Supplier; import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; @@ -73,6 +74,12 @@ void setUp() { when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); when(this.itemStorageManager.reserve(this.parcel.sender())) .thenReturn(Optional.of(this.reservation)); + when(this.parcelService.serializeParcelOperation(any(), any())) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Supplier> operation = invocation.getArgument(1); + return operation.get(); + }); } @Test @@ -80,7 +87,7 @@ void successCompletesOnlyAfterDeliveryIsPersistedAndTaskIsScheduled() { CompletableFuture deliveryCreated = new CompletableFuture<>(); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); @@ -106,7 +113,7 @@ void fullLockerCompletesFalseWithoutPersistingParcel() { assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); - verify(this.parcelService, never()).send(any(), any(), any()); + verify(this.parcelService, never()).sendWithinParcelOperation(any(), any(), any()); verify(this.reservation).close(); } @@ -121,14 +128,14 @@ void fullnessLookupFailureCompletesExceptionally() { assertInstanceOf(ParcelOperationException.class, exception.getCause()); assertSame(lookupFailure, exception.getCause().getCause()); - verify(this.parcelService, never()).send(any(), any(), any()); + verify(this.parcelService, never()).sendWithinParcelOperation(any(), any(), any()); } @Test void cancelledPersistenceStepCompletesFalseWithoutContinuing() { when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(false)); assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); @@ -142,7 +149,7 @@ void parcelPersistenceFailureCompletesExceptionally() { new IllegalStateException("persistence failed"); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.failedFuture(persistenceFailure)); CompletionException exception = assertThrows(CompletionException.class, @@ -158,11 +165,11 @@ void storageDeleteFailureRollsBackAndCompletesExceptionally() { IllegalStateException deleteFailure = new IllegalStateException("delete failed"); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()) .thenReturn(CompletableFuture.failedFuture(deleteFailure)); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception = assertThrows(CompletionException.class, @@ -170,7 +177,7 @@ void storageDeleteFailureRollsBackAndCompletesExceptionally() { assertInstanceOf(ParcelOperationException.class, exception.getCause()); assertSame(deleteFailure, exception.getCause().getCause()); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); verify(this.deliveryManager, never()).create(any(), any()); verify(this.scheduler, never()).runLaterAsync(any(), any()); } @@ -180,10 +187,10 @@ void synchronousStorageDeleteFailureStillRollsBack() { IllegalStateException deleteFailure = new IllegalStateException("delete failed"); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()).thenThrow(deleteFailure); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception = assertThrows(CompletionException.class, @@ -192,7 +199,7 @@ void synchronousStorageDeleteFailureStillRollsBack() { ParcelOperationException operationException = assertInstanceOf(ParcelOperationException.class, exception.getCause()); assertSame(deleteFailure, operationException.getCause()); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); verify(this.deliveryManager, never()).create(any(), any()); } @@ -203,7 +210,7 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { CompletableFuture rolledBack = new CompletableFuture<>(); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); @@ -211,18 +218,18 @@ void synchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { .thenThrow(createFailure); when(this.reservation.restore(this.items)) .thenReturn(restored); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(rolledBack); CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); assertFalse(result.isDone()); - verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + verify(this.parcelService, never()).rollbackSendWithinParcelOperation(this.sender, this.parcel); restored.complete(mock(ItemStorage.class)); assertFalse(result.isDone()); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); rolledBack.complete(null); CompletionException exception = @@ -238,7 +245,7 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { IllegalStateException createFailure = new IllegalStateException("create failed"); when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); @@ -246,7 +253,7 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { .thenReturn(CompletableFuture.failedFuture(createFailure)); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception = assertThrows(CompletionException.class, @@ -255,7 +262,7 @@ void asynchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { assertInstanceOf(ParcelOperationException.class, exception.getCause()); assertSame(createFailure, exception.getCause().getCause()); verify(this.reservation).restore(this.items); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); verify(this.scheduler, never()).runLaterAsync(any(), any()); verify(this.noticeService, times(1)).player(eq(this.parcel.sender()), any()); } @@ -273,7 +280,7 @@ void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBackExceptional .thenThrow(schedulingFailure); when(this.deliveryManager.delete(this.parcel.uuid())).thenReturn(deliveryDeleted); when(this.reservation.restore(this.items)).thenReturn(restored); - when(this.parcelService.rollbackSend(this.sender, this.parcel)).thenReturn(rolledBack); + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)).thenReturn(rolledBack); CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); @@ -283,10 +290,10 @@ void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBackExceptional deliveryDeleted.complete(true); verify(this.reservation).restore(this.items); - verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + verify(this.parcelService, never()).rollbackSendWithinParcelOperation(this.sender, this.parcel); restored.complete(mock(ItemStorage.class)); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); assertFalse(result.isDone()); rolledBack.complete(null); @@ -320,7 +327,7 @@ public void close() { .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("restore failed"))); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception; @@ -332,7 +339,7 @@ public void close() { } assertInstanceOf(ParcelOperationException.class, exception.getCause()); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); verify(this.noticeService, times(1)).player(eq(this.parcel.sender()), any()); verify(this.reservation).close(); assertTrue(records.stream().anyMatch(record -> @@ -352,7 +359,7 @@ void deliveryDeleteFailureCompletesExceptionallyInsteadOfFalse() { .thenReturn(CompletableFuture.completedFuture(false)); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.completedFuture(null)); CompletionException exception = assertThrows(CompletionException.class, @@ -360,7 +367,7 @@ void deliveryDeleteFailureCompletesExceptionallyInsteadOfFalse() { assertInstanceOf(ParcelOperationException.class, exception.getCause()); verify(this.reservation).restore(this.items); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); } @Test @@ -378,7 +385,7 @@ void schedulingCompensationAttemptsEveryStepAndAggregatesCleanupFailures() { .thenReturn(CompletableFuture.failedFuture(deliveryCleanupFailure)); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.failedFuture(restoreFailure)); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.failedFuture(rollbackFailure)); CompletionException exception = assertThrows(CompletionException.class, @@ -393,7 +400,7 @@ void schedulingCompensationAttemptsEveryStepAndAggregatesCleanupFailures() { ); verify(this.deliveryManager).delete(this.parcel.uuid()); verify(this.reservation).restore(this.items); - verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); } @Test @@ -403,7 +410,7 @@ void rollbackFailureCompletesExceptionallyInsteadOfFalse() { .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("create failed"))); when(this.reservation.restore(this.items)) .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); - when(this.parcelService.rollbackSend(this.sender, this.parcel)) + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("rollback failed"))); CompletionException exception = assertThrows(CompletionException.class, @@ -424,13 +431,13 @@ void successNoticeFailureDoesNotCompensateCommittedSend() { verify(this.deliveryManager, never()).delete(this.parcel.uuid()); verify(this.reservation, never()).restore(this.items); - verify(this.parcelService, never()).rollbackSend(this.sender, this.parcel); + verify(this.parcelService, never()).rollbackSendWithinParcelOperation(this.sender, this.parcel); } private void stubSuccessfulStart() { when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) .thenReturn(CompletableFuture.completedFuture(false)); - when(this.parcelService.send(this.sender, this.parcel, this.items)) + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) .thenReturn(CompletableFuture.completedFuture(true)); when(this.reservation.delete()) .thenReturn(CompletableFuture.completedFuture(true)); diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java index 8dab0e0e..f4c914bd 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -14,8 +14,10 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,6 +43,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import net.milkbowl.vault.economy.Economy; import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Server; @@ -50,10 +54,171 @@ import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.MockedStatic; class ParcelServiceImplTest { + @Test + void releasesParcelGateBeforeCompletingOutwardFuture() { + OperationFixture fixture = new OperationFixture(); + CompletableFuture contentSaved = new CompletableFuture<>(); + when(fixture.parcelRepository.saveIfAbsent(fixture.parcel)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.save(any())).thenReturn(contentSaved); + when(fixture.parcelRepository.delete(fixture.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.delete(fixture.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + AtomicBoolean callbackDeleteCompleted = new AtomicBoolean(); + + CompletableFuture send = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + send.whenComplete((ignored, throwable) -> callbackDeleteCompleted.set( + fixture.service.delete(fixture.parcel.uuid()).isDone())); + + contentSaved.complete(null); + + assertTrue(send.join()); + assertTrue(callbackDeleteCompleted.get()); + } + + @Test + void sameParcelMutationFromSendEventFailsFastInsteadOfQueuingBehindItself() { + OperationFixture fixture = new OperationFixture(); + when(fixture.parcelRepository.saveIfAbsent(fixture.parcel)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.save(any())) + .thenReturn(CompletableFuture.completedFuture(null)); + AtomicReference> reentrant = new AtomicReference<>(); + AtomicReference> reentrantBulk = new AtomicReference<>(); + doAnswer(invocation -> { + reentrant.set(fixture.service.delete(fixture.parcel.uuid())); + reentrantBulk.set( + fixture.service.deleteAll(fixture.sender, fixture.noticeService)); + return null; + }).when(fixture.pluginManager).callEvent(any()); + + assertTrue(fixture.service.send( + fixture.sender, fixture.parcel, fixture.items).join()); + + assertTrue(reentrant.get().isCompletedExceptionally()); + assertInstanceOf(IllegalStateException.class, + assertThrows(CompletionException.class, () -> reentrant.get().join()).getCause()); + assertTrue(reentrantBulk.get().isCompletedExceptionally()); + assertInstanceOf(IllegalStateException.class, + assertThrows( + CompletionException.class, () -> reentrantBulk.get().join()).getCause()); + verify(fixture.parcelRepository, never()).delete(fixture.parcel.uuid()); + verify(fixture.parcelRepository, never()).deleteAll(); + } + + @Test + void deleteAllWaitsForInFlightParcelOperationsAndBlocksNewOnes() { + OperationFixture fixture = new OperationFixture(); + CompletableFuture contentSaved = new CompletableFuture<>(); + CompletableFuture bulkDeleted = new CompletableFuture<>(); + when(fixture.parcelRepository.saveIfAbsent(fixture.parcel)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.save(any())).thenReturn(contentSaved); + when(fixture.parcelRepository.deleteAll()).thenReturn(bulkDeleted); + + CompletableFuture send = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + CompletableFuture deleteAll = + fixture.service.deleteAll(fixture.sender, fixture.noticeService); + CompletableFuture update = fixture.service.update(fixture.parcel); + + assertFalse(deleteAll.isDone()); + assertFalse(update.isDone()); + verify(fixture.parcelRepository, never()).deleteAll(); + verify(fixture.parcelRepository, never()).update(fixture.parcel); + + contentSaved.complete(null); + + assertTrue(send.join()); + verify(fixture.parcelRepository).deleteAll(); + assertFalse(update.isDone()); + } + + @Test + void deleteAndUpdateWaitForInFlightSendOfSameParcel() { + OperationFixture fixture = new OperationFixture(); + CompletableFuture contentSaved = new CompletableFuture<>(); + when(fixture.parcelRepository.saveIfAbsent(fixture.parcel)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.save(any())).thenReturn(contentSaved); + when(fixture.parcelRepository.delete(fixture.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.delete(fixture.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fixture.parcelRepository.update(fixture.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + + CompletableFuture send = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + CompletableFuture delete = fixture.service.delete(fixture.parcel.uuid()); + CompletableFuture update = fixture.service.update(fixture.parcel); + + assertFalse(delete.isDone()); + assertFalse(update.isDone()); + verify(fixture.parcelRepository, never()).delete(fixture.parcel.uuid()); + verify(fixture.parcelRepository, never()).update(fixture.parcel); + + contentSaved.complete(null); + + assertTrue(send.join()); + assertTrue(delete.join()); + update.join(); + InOrder order = org.mockito.Mockito.inOrder( + fixture.contentRepository, fixture.parcelRepository); + order.verify(fixture.contentRepository).save(any()); + order.verify(fixture.parcelRepository).delete(fixture.parcel.uuid()); + order.verify(fixture.contentRepository).delete(fixture.parcel.uuid()); + order.verify(fixture.parcelRepository).update(fixture.parcel); + } + + @Test + void staleSendCompensationFinishesBeforeDeleteAndReplacementInsert() { + OperationFixture fixture = new OperationFixture(); + CompletableFuture compensationDelete = new CompletableFuture<>(); + when(fixture.parcelRepository.saveIfAbsent(fixture.parcel)) + .thenReturn( + CompletableFuture.completedFuture(true), + CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.save(any())) + .thenReturn( + CompletableFuture.failedFuture(new IllegalStateException("first save failed")), + CompletableFuture.completedFuture(null)); + when(fixture.parcelRepository.delete(fixture.parcel.uuid())) + .thenReturn(compensationDelete, CompletableFuture.completedFuture(true)); + when(fixture.contentRepository.delete(fixture.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + + CompletableFuture first = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + CompletableFuture delete = fixture.service.delete(fixture.parcel.uuid()); + CompletableFuture replacement = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + + verify(fixture.parcelRepository, times(1)).saveIfAbsent(fixture.parcel); + assertFalse(delete.isDone()); + assertFalse(replacement.isDone()); + + compensationDelete.complete(true); + + assertThrows(CompletionException.class, first::join); + assertTrue(delete.join()); + assertTrue(replacement.join()); + assertEquals( + List.of("saveIfAbsent", "delete", "delete", "saveIfAbsent"), + mockingDetails(fixture.parcelRepository).getInvocations().stream() + .map(invocation -> invocation.getMethod().getName()) + .filter(name -> name.equals("saveIfAbsent") || name.equals("delete")) + .toList() + ); + } + @Test void collectRejectsForgedReceiverFromAuthoritativeRecordWithoutSideEffects() { CollectFixture fixture = new CollectFixture(); @@ -637,4 +802,33 @@ private void runNextMainTask() { task.run(); } } + + private static final class OperationFixture { + + private final NoticeService noticeService = mock(NoticeService.class); + private final ParcelRepository parcelRepository = mock(ParcelRepository.class); + private final ParcelContentRepository contentRepository = + mock(ParcelContentRepository.class); + private final CollectedParcelRepository collectedRepository = + mock(CollectedParcelRepository.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final PluginConfig config = new PluginConfig(); + private final Economy economy = mock(Economy.class); + private final Server server = mock(Server.class); + private final PluginManager pluginManager = mock(PluginManager.class); + private final Player sender = mock(Player.class); + private final Parcel parcel = parcel(); + private final List items = List.of(mock(ItemStack.class)); + private final ParcelServiceImpl service; + + private OperationFixture() { + when(this.server.getPluginManager()).thenReturn(this.pluginManager); + when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); + when(this.sender.hasPermission("parcellockers.fee.bypass")).thenReturn(true); + when(this.items.getFirst().clone()).thenReturn(this.items.getFirst()); + this.service = new ParcelServiceImpl( + this.noticeService, this.parcelRepository, this.contentRepository, + this.collectedRepository, this.scheduler, this.config, this.economy, this.server); + } + } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java index 29014a27..393cef18 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java @@ -1,20 +1,74 @@ package com.eternalcode.parcellockers.parcel.task; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.scheduler.Scheduler; import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask.Decision; +import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import java.time.Duration; import java.time.Instant; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; class ParcelSendTaskTest { + @Test + void serializesReadDecisionAndDeliveryCommitUnderParcelUuid() { + Parcel sent = parcel(ParcelStatus.SENT); + Delivery due = new Delivery(sent.uuid(), Instant.now().minusSeconds(1)); + PluginParcelService parcelService = mock(PluginParcelService.class); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + PluginManager pluginManager = mock(PluginManager.class); + when(parcelService.serializeParcelOperation(eq(sent.uuid()), any())) + .thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Supplier> operation = invocation.getArgument(1); + return operation.get(); + }); + when(parcelService.getAuthoritativeWithinParcelOperation(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(sent))); + when(deliveryManager.get(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(due))); + when(parcelService.updateWithinParcelOperation(any())) + .thenReturn(CompletableFuture.completedFuture(null)); + when(deliveryManager.delete(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + + new ParcelSendTask(sent, parcelService, deliveryManager, scheduler).run(); + } + + verify(parcelService).serializeParcelOperation(eq(sent.uuid()), any()); + verify(parcelService).getAuthoritativeWithinParcelOperation(sent.uuid()); + verify(parcelService, never()).get(sent.uuid()); + verify(parcelService).updateWithinParcelOperation( + new Parcel(sent.uuid(), sent.sender(), sent.name(), sent.description(), + sent.priority(), sent.receiver(), sent.size(), sent.entryLocker(), + sent.destinationLocker(), ParcelStatus.DELIVERED)); + verify(parcelService, never()).update(any()); + } + private static Parcel parcel(ParcelStatus status) { UUID id = UUID.randomUUID(); return new Parcel(id, UUID.randomUUID(), "n", "d", false, UUID.randomUUID(), diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTableMappingTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTableMappingTest.java new file mode 100644 index 00000000..d1376d83 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTableMappingTest.java @@ -0,0 +1,19 @@ +package com.eternalcode.parcellockers.returns.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.j256.ormlite.field.DatabaseField; +import org.junit.jupiter.api.Test; + +class CollectedParcelTableMappingTest { + + @Test + void keepsHistoricalSnakeCaseCollectedAtColumn() throws NoSuchFieldException { + DatabaseField mapping = CollectedParcelTable.class + .getDeclaredField("collectedAt") + .getAnnotation(DatabaseField.class); + + assertEquals("collected_at", mapping.columnName()); + assertEquals("collected_at", CollectedParcelTable.COLLECTED_AT_COLUMN); + } +} From 7d14ed18123830ea3f5aef7febceb16d558133a1 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:23:27 +0200 Subject: [PATCH 18/19] fix: harden parcel operation coordination --- .../parcellockers/PublicParcelService.java | 11 ++ .../parcel/service/AdminParcelService.java | 21 ++- .../parcel/service/ParcelServiceImpl.java | 63 ++++++--- .../parcel/service/PluginParcelService.java | 8 ++ .../PublicParcelServiceTest.java | 51 ++++++- .../service/AdminParcelServiceTest.java | 128 +++++++++++++++++- .../parcel/service/ParcelServiceImplTest.java | 115 +++++++++++++++- 7 files changed, 365 insertions(+), 32 deletions(-) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java index aba40188..d46a222f 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java @@ -38,6 +38,9 @@ final class PublicParcelService implements ParcelService { @Override public CompletableFuture send(Player sender, Parcel parcel, List items) { + if (this.delegate.isParcelOperationCallbackActive()) { + return callbackMutationFailure(); + } try { validateSend(sender, parcel, items); List snapshot = items.stream() @@ -67,6 +70,9 @@ public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus ex @Override public CompletableFuture collect(Player player, Parcel parcel) { + if (this.delegate.isParcelOperationCallbackActive()) { + return callbackMutationFailure(); + } try { validateCollect(player, parcel); return this.submitAsync( @@ -212,6 +218,11 @@ private static ParcelOperationException operationFailure( return new ParcelOperationException(message, cause); } + private static CompletableFuture callbackMutationFailure() { + return CompletableFuture.failedFuture(new IllegalStateException( + "Parcel mutations cannot be started from a parcel event callback")); + } + private static Throwable unwrap(Throwable throwable) { if (throwable instanceof CompletionException && throwable.getCause() != null) { return throwable.getCause(); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index d3546c99..2c1e7835 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -72,10 +72,19 @@ public CompletableFuture changeStatus(Parcel parcel, ParcelStatus st if (parcel.status() == ParcelStatus.COLLECTED) { return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); } + return this.parcelService.serializeParcelOperation( + parcel.uuid(), () -> this.changeStatusWithinParcelOperation(parcel, status)); + } + + private CompletableFuture changeStatusWithinParcelOperation( + Parcel parcel, + ParcelStatus status + ) { Parcel updated = withStatus(parcel, status); // 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 -> { + return this.parcelService.updateIfStatusWithinParcelOperation( + updated, parcel.status()).thenCompose(applied -> { if (!Boolean.TRUE.equals(applied)) { return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); } @@ -148,8 +157,16 @@ public CompletableFuture changeDestination(Parcel parcel, UUID desti } public CompletableFuture changePriority(Parcel parcel, boolean newPriority) { + return this.parcelService.serializeParcelOperation( + parcel.uuid(), () -> this.changePriorityWithinParcelOperation(parcel, newPriority)); + } + + private CompletableFuture changePriorityWithinParcelOperation( + Parcel parcel, + boolean newPriority + ) { Parcel updated = withPriority(parcel, newPriority); - return this.parcelService.update(updated).thenCompose(ignored -> { + return this.parcelService.updateWithinParcelOperation(updated).thenCompose(ignored -> { if (parcel.status() != ParcelStatus.SENT || newPriority == parcel.priority()) { return CompletableFuture.completedFuture(EditResult.ok()); } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index dcaf9510..d405923a 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -32,6 +32,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import net.milkbowl.vault.economy.Economy; @@ -48,6 +49,17 @@ public class ParcelServiceImpl implements PluginParcelService { private static final long CACHE_EXPIRE_HOURS = 3; private static final long CACHE_MAX_SIZE = 10_000; + private static final Executor OPERATION_HANDOFF = command -> { + try { + Thread.ofVirtual() + .name("parcel-operation-handoff") + .start(command); + } catch (Throwable virtualThreadFailure) { + Thread fallback = new Thread(command, "parcel-operation-handoff-fallback"); + fallback.setDaemon(true); + fallback.start(); + } + }; private final NoticeService noticeService; private final ParcelRepository parcelRepository; @@ -374,10 +386,12 @@ public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus ex Objects.requireNonNull(updated, "Updated parcel cannot be null"); Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); return this.serializeParcelOperation( - updated.uuid(), () -> this.updateIfStatusWithinOperation(updated, expectedStatus)); + updated.uuid(), + () -> this.updateIfStatusWithinParcelOperation(updated, expectedStatus)); } - private CompletableFuture updateIfStatusWithinOperation( + @Override + public CompletableFuture updateIfStatusWithinParcelOperation( Parcel updated, ParcelStatus expectedStatus ) { @@ -815,18 +829,11 @@ public CompletableFuture serializeParcelOperation( running = Objects.requireNonNull( operation.get(), "Parcel operation returned a null future"); } catch (Throwable throwable) { - gate.complete(null); - result.completeExceptionally(throwable); + completeSerializedOperation(gate, result, null, throwable); return; } - running.whenComplete((value, throwable) -> { - gate.complete(null); - if (throwable != null) { - result.completeExceptionally(throwable); - } else { - result.complete(value); - } - }); + running.whenComplete((value, throwable) -> + completeSerializedOperation(gate, result, value, throwable)); }); gate.whenComplete((ignored, throwable) -> { synchronized (this.parcelOperationTails) { @@ -853,6 +860,11 @@ public void runParcelOperationCallback(UUID parcel, Runnable callback) { } } + @Override + public boolean isParcelOperationCallbackActive() { + return this.parcelOperationCallback.get() != null; + } + private CompletableFuture serializeAllParcelOperations( Supplier> operation ) { @@ -877,18 +889,11 @@ private CompletableFuture serializeAllParcelOperations( running = Objects.requireNonNull( operation.get(), "Global parcel operation returned a null future"); } catch (Throwable throwable) { - gate.complete(null); - result.completeExceptionally(throwable); + completeSerializedOperation(gate, result, null, throwable); return; } - running.whenComplete((value, throwable) -> { - gate.complete(null); - if (throwable != null) { - result.completeExceptionally(throwable); - } else { - result.complete(value); - } - }); + running.whenComplete((value, throwable) -> + completeSerializedOperation(gate, result, value, throwable)); }); gate.whenComplete((ignored, throwable) -> { synchronized (this.parcelOperationTails) { @@ -900,6 +905,20 @@ private CompletableFuture serializeAllParcelOperations( return result; } + private static void completeSerializedOperation( + CompletableFuture gate, + CompletableFuture result, + T value, + Throwable throwable + ) { + OPERATION_HANDOFF.execute(() -> gate.complete(null)); + if (throwable != null) { + result.completeExceptionally(throwable); + } else { + result.complete(value); + } + } + @Override public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { Objects.requireNonNull(sender, "Sender cannot be null"); diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java index d17311fb..73c7f7c8 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java @@ -2,6 +2,7 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -30,10 +31,17 @@ CompletableFuture serializeParcelOperation( CompletableFuture updateWithinParcelOperation(Parcel parcel); + CompletableFuture updateIfStatusWithinParcelOperation( + Parcel parcel, + ParcelStatus expectedStatus + ); + CompletableFuture> getAuthoritativeWithinParcelOperation(UUID parcel); void runParcelOperationCallback(UUID parcel, Runnable callback); + boolean isParcelOperationCallbackActive(); + void invalidate(UUID uuid); CompletableFuture delete(CommandSender sender, Parcel parcel); diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java index 8e394e88..aa04d7d3 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java @@ -19,6 +19,7 @@ import com.eternalcode.parcellockers.parcel.ParcelSize; import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; +import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; import com.eternalcode.parcellockers.parcel.service.PluginParcelService; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; import com.eternalcode.parcellockers.shared.exception.ValidationException; @@ -35,6 +36,41 @@ class PublicParcelServiceTest { + @Test + void sendFromParcelEventCallbackFailsFastBeforeAsyncScheduling() { + Fixture fixture = new Fixture(); + + fixture.runInParcelCallback(() -> { + CompletableFuture result = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertInstanceOf(IllegalStateException.class, exception.getCause()); + }); + verify(fixture.scheduler, never()).runAsync(any()); + verify(fixture.dispatcher, never()).dispatch(any(), any(), any()); + } + + @Test + void collectFromParcelEventCallbackFailsFastBeforeAsyncScheduling() { + Fixture fixture = new Fixture(); + Parcel delivered = withStatus(fixture.parcel, ParcelStatus.DELIVERED); + + fixture.runInParcelCallback(() -> { + CompletableFuture result = + fixture.service.collect(fixture.receiver, delivered); + + assertTrue(result.isCompletedExceptionally()); + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertInstanceOf(IllegalStateException.class, exception.getCause()); + }); + verify(fixture.scheduler, never()).runAsync(any()); + verify(fixture.delegate, never()).collect(any(), any()); + } + @Test void sendRejectsSenderMismatchBeforeSchedulingDispatch() { Fixture fixture = new Fixture(); @@ -204,7 +240,16 @@ private static Parcel withStatus(Parcel parcel, ParcelStatus status) { private static final class Fixture { - private final PluginParcelService delegate = mock(PluginParcelService.class); + private final ParcelServiceImpl callbackContext = + new ParcelServiceImpl(mock(), mock(), mock(), mock(), mock(), mock(), mock(), mock()); + private final PluginParcelService delegate = mock( + PluginParcelService.class, + invocation -> { + if (invocation.getMethod().getName().equals("isParcelOperationCallbackActive")) { + return this.callbackContext.isParcelOperationCallbackActive(); + } + return org.mockito.Answers.RETURNS_DEFAULTS.answer(invocation); + }); private final ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); private final Scheduler scheduler = mock(Scheduler.class); private final PublicParcelService service = @@ -230,5 +275,9 @@ private Fixture() { when(this.receiver.getUniqueId()).thenReturn(this.parcel.receiver()); when(this.items.get(0).clone()).thenReturn(this.items.get(0)); } + + private void runInParcelCallback(Runnable callback) { + this.callbackContext.runParcelOperationCallback(this.parcel.uuid(), callback); + } } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java index aa33f5a2..07757b72 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -1,13 +1,29 @@ package com.eternalcode.parcellockers.parcel.service; 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; +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.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.DeliveryManager; 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.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; import org.junit.jupiter.api.Test; class AdminParcelServiceTest { @@ -30,6 +46,80 @@ void changeStatusRefusesCollectedParcelWithoutTouchingAnyCollaborator() { assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); } + @Test + void changeStatusKeepsParcelGateUntilDeliveryIsArmed() { + Parcel delivered = parcel(ParcelStatus.DELIVERED, false); + AtomicBoolean gateHeld = new AtomicBoolean(); + PluginParcelService parcelService = parcelServiceWithWithinStatusUpdate(); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + CompletableFuture deliveryUpdated = new CompletableFuture<>(); + + runSerializedOperationsImmediately(parcelService, delivered.uuid(), gateHeld); + when(deliveryManager.get(delivered.uuid())).thenAnswer(invocation -> { + assertTrue(gateHeld.get(), "delivery read must run while the parcel gate is held"); + return CompletableFuture.completedFuture(Optional.empty()); + }); + when(deliveryManager.update(eq(delivered.uuid()), any())).thenAnswer(invocation -> { + assertTrue(gateHeld.get(), "delivery write must run while the parcel gate is held"); + return deliveryUpdated; + }); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + CompletableFuture result = + service.changeStatus(delivered, ParcelStatus.SENT); + + assertFalse(result.isDone(), "the gate must cover the pending delivery write"); + assertTrue(gateHeld.get()); + + deliveryUpdated.complete(new Delivery(delivered.uuid(), Instant.now())); + + assertEquals(EditResult.Status.OK, result.join().status()); + assertFalse(gateHeld.get()); + verify(parcelService).serializeParcelOperation(eq(delivered.uuid()), any()); + verify(parcelService, never()).updateIfStatus(any(), any()); + verify(parcelService).updateIfStatusWithinParcelOperation( + any(), eq(ParcelStatus.DELIVERED)); + } + + @Test + void changePriorityKeepsParcelGateUntilDeliveryAndSchedulingComplete() { + Parcel sent = parcel(ParcelStatus.SENT, false); + AtomicBoolean gateHeld = new AtomicBoolean(); + PluginParcelService parcelService = mock(PluginParcelService.class); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + CompletableFuture deliveryUpdated = new CompletableFuture<>(); + Delivery current = new Delivery(sent.uuid(), Instant.now().plus(Duration.ofMinutes(10))); + + runSerializedOperationsImmediately(parcelService, sent.uuid(), gateHeld); + when(parcelService.updateWithinParcelOperation(any())) + .thenReturn(CompletableFuture.completedFuture(null)); + when(deliveryManager.get(sent.uuid())).thenAnswer(invocation -> { + assertTrue(gateHeld.get(), "delivery read must run while the parcel gate is held"); + return CompletableFuture.completedFuture(Optional.of(current)); + }); + when(deliveryManager.update(eq(sent.uuid()), any())).thenAnswer(invocation -> { + assertTrue(gateHeld.get(), "delivery write must run while the parcel gate is held"); + return deliveryUpdated; + }); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + CompletableFuture result = service.changePriority(sent, true); + + assertFalse(result.isDone(), "the gate must cover the pending delivery write"); + assertTrue(gateHeld.get()); + + deliveryUpdated.complete(new Delivery(sent.uuid(), Instant.now())); + + assertEquals(EditResult.Status.OK, result.join().status()); + assertFalse(gateHeld.get()); + verify(scheduler).runLaterAsync(any(), any()); + verify(parcelService).serializeParcelOperation(eq(sent.uuid()), any()); + verify(parcelService, never()).update(any()); + verify(parcelService).updateWithinParcelOperation(any()); + } + @Test void capacityMatchesContentGuiUsableSlots() { assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); @@ -87,4 +177,40 @@ void unchangedPriorityKeepsTimestamp() { assertEquals(oldTs, shifted); } + + private static PluginParcelService parcelServiceWithWithinStatusUpdate() { + PluginParcelService parcelService = mock(PluginParcelService.class); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + return parcelService; + } + + private static void runSerializedOperationsImmediately( + PluginParcelService parcelService, + UUID parcelId, + AtomicBoolean gateHeld + ) { + when(parcelService.serializeParcelOperation(eq(parcelId), any())) + .thenAnswer(invocation -> { + gateHeld.set(true); + @SuppressWarnings("unchecked") + Supplier> operation = invocation.getArgument(1); + CompletableFuture result = operation.get(); + return result.whenComplete((ignored, throwable) -> gateHeld.set(false)); + }); + } + + private static AdminParcelService service( + PluginParcelService parcelService, + DeliveryManager deliveryManager, + Scheduler scheduler + ) { + return new AdminParcelService( + parcelService, null, deliveryManager, null, new PluginConfig(), scheduler); + } + + private static Parcel parcel(ParcelStatus status, boolean priority) { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "name", "description", priority, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); + } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java index f4c914bd..1ea6f97c 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -18,6 +18,7 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -35,13 +36,16 @@ import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; import com.eternalcode.parcellockers.shared.exception.ValidationException; +import java.lang.reflect.Method; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Queue; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -60,7 +64,7 @@ class ParcelServiceImplTest { @Test - void releasesParcelGateBeforeCompletingOutwardFuture() { + void outwardCompletionCallbackCanQueueAndAwaitFollowingMutation() { OperationFixture fixture = new OperationFixture(); CompletableFuture contentSaved = new CompletableFuture<>(); when(fixture.parcelRepository.saveIfAbsent(fixture.parcel)) @@ -70,17 +74,105 @@ void releasesParcelGateBeforeCompletingOutwardFuture() { .thenReturn(CompletableFuture.completedFuture(true)); when(fixture.contentRepository.delete(fixture.parcel.uuid())) .thenReturn(CompletableFuture.completedFuture(true)); - AtomicBoolean callbackDeleteCompleted = new AtomicBoolean(); + AtomicReference> callbackDelete = new AtomicReference<>(); CompletableFuture send = fixture.service.send(fixture.sender, fixture.parcel, fixture.items); - send.whenComplete((ignored, throwable) -> callbackDeleteCompleted.set( - fixture.service.delete(fixture.parcel.uuid()).isDone())); + send.whenComplete((ignored, throwable) -> + callbackDelete.set(fixture.service.delete(fixture.parcel.uuid()))); contentSaved.complete(null); assertTrue(send.join()); - assertTrue(callbackDeleteCompleted.get()); + assertTrue(callbackDelete.get().join()); + } + + @Test + void queuedParcelCallbacksCanJoinPredecessorAndSuccessorWithoutDeadlock() + throws Exception { + OperationFixture fixture = new OperationFixture(); + CompletableFuture firstRunning = new CompletableFuture<>(); + CompletableFuture first = fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> firstRunning); + AtomicReference> secondReference = new AtomicReference<>(); + CompletableFuture firstCallback = + first.whenComplete((ignored, throwable) -> secondReference.get().join()); + CompletableFuture second = fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> CompletableFuture.completedFuture(null)); + secondReference.set(second); + CompletableFuture secondCallback = + second.whenComplete((ignored, throwable) -> first.join()); + + Thread releaser = new Thread(() -> firstRunning.complete(null)); + releaser.setDaemon(true); + releaser.start(); + releaser.join(1_000); + + assertFalse(releaser.isAlive(), + "gate completion must not inline a successor before the predecessor result completes"); + CompletableFuture.allOf(first, second, firstCallback, secondCallback) + .get(2, TimeUnit.SECONDS); + } + + @Test + void globalBarrierCallbacksCanJoinQueuedSuccessorWithoutDeadlock() + throws Exception { + OperationFixture fixture = new OperationFixture(); + CompletableFuture firstRunning = new CompletableFuture<>(); + CompletableFuture first = fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> firstRunning); + + CompletableFuture bulk = serializeAllParcelOperations( + fixture.service, () -> CompletableFuture.completedFuture(null)); + AtomicReference> successorReference = + new AtomicReference<>(); + CompletableFuture bulkCallback = + bulk.whenComplete((ignored, throwable) -> successorReference.get().join()); + CompletableFuture successor = fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> CompletableFuture.completedFuture(null)); + successorReference.set(successor); + CompletableFuture successorCallback = + successor.whenComplete((ignored, throwable) -> bulk.join()); + + Thread releaser = new Thread(() -> firstRunning.complete(null)); + releaser.setDaemon(true); + releaser.start(); + releaser.join(1_000); + + assertFalse(releaser.isAlive(), + "global barrier completion must not inline its queued successor"); + CompletableFuture.allOf( + first, bulk, successor, bulkCallback, successorCallback) + .get(2, TimeUnit.SECONDS); + } + + @Test + void longParcelOperationChainDrainsWithoutRecursiveCompletion() + throws Exception { + OperationFixture fixture = new OperationFixture(); + CompletableFuture firstRunning = new CompletableFuture<>(); + List> operations = new ArrayList<>(); + operations.add(fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> firstRunning)); + AtomicInteger started = new AtomicInteger(); + int successors = 2_000; + for (int index = 0; index < successors; index++) { + operations.add(fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> { + started.incrementAndGet(); + return CompletableFuture.completedFuture(null); + })); + } + + Thread releaser = new Thread(() -> firstRunning.complete(null)); + releaser.setDaemon(true); + releaser.start(); + releaser.join(5_000); + + assertFalse(releaser.isAlive(), "releasing a long queue must not recurse inline"); + CompletableFuture.allOf(operations.toArray(CompletableFuture[]::new)) + .get(5, TimeUnit.SECONDS); + assertEquals(successors, started.get()); } @Test @@ -137,7 +229,7 @@ void deleteAllWaitsForInFlightParcelOperationsAndBlocksNewOnes() { contentSaved.complete(null); assertTrue(send.join()); - verify(fixture.parcelRepository).deleteAll(); + verify(fixture.parcelRepository, timeout(1_000)).deleteAll(); assertFalse(update.isDone()); } @@ -720,6 +812,17 @@ void rollbackSendTreatsFalseDeleteResultsAsCleanupFailures() { verify(contentRepository).delete(parcel.uuid()); } + @SuppressWarnings("unchecked") + private static CompletableFuture serializeAllParcelOperations( + ParcelServiceImpl service, + java.util.function.Supplier> operation + ) throws ReflectiveOperationException { + Method method = ParcelServiceImpl.class.getDeclaredMethod( + "serializeAllParcelOperations", java.util.function.Supplier.class); + method.setAccessible(true); + return (CompletableFuture) method.invoke(service, operation); + } + private static Parcel parcel() { return new Parcel( UUID.randomUUID(), From 820eea9288dc0edea1e1a63e16cc6ae6d71b734f Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:15:01 +0200 Subject: [PATCH 19/19] fix: preserve admin parcel lifecycle integrity --- .../parcel/service/AdminParcelService.java | 351 ++++++++++++++---- .../parcel/service/ParcelServiceImpl.java | 36 +- .../service/AdminParcelServiceTest.java | 299 ++++++++++++++- .../parcel/service/ParcelServiceImplTest.java | 32 +- 4 files changed, 625 insertions(+), 93 deletions(-) diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index 2c1e7835..58607c6d 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -3,6 +3,7 @@ import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; 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.parcel.Parcel; @@ -11,8 +12,12 @@ import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; import java.time.Duration; import java.time.Instant; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; public class AdminParcelService { @@ -50,16 +55,13 @@ public static Instant shiftedDeliveryTimestamp(Instant oldTimestamp, boolean old return shifted.isBefore(now) ? now : shifted; } - private CompletableFuture persist(Parcel updated) { - return this.parcelService.update(updated).thenApply(ignored -> EditResult.ok()); - } - public CompletableFuture changeName(Parcel parcel, String name) { - return this.persist(withName(parcel, name)); + return this.editAuthoritative(parcel, current -> withName(current, name)); } public CompletableFuture changeDescription(Parcel parcel, String description) { - return this.persist(withDescription(parcel, description)); + return this.editAuthoritative( + parcel, current -> withDescription(current, description)); } /** @@ -69,9 +71,6 @@ public CompletableFuture changeDescription(Parcel parcel, String des * of COLLECTED. */ public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { - if (parcel.status() == ParcelStatus.COLLECTED) { - return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); - } return this.parcelService.serializeParcelOperation( parcel.uuid(), () -> this.changeStatusWithinParcelOperation(parcel, status)); } @@ -80,51 +79,78 @@ private CompletableFuture changeStatusWithinParcelOperation( Parcel parcel, ParcelStatus status ) { - Parcel updated = withStatus(parcel, status); - // 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.getAuthoritativeWithinParcelOperation(parcel.uuid()) + .thenCompose(optional -> { + if (optional.isEmpty() + || optional.get().status() == ParcelStatus.COLLECTED) { + return parcelUnavailable(); + } + Parcel current = optional.get(); + if (status == current.status()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + return this.deliveryManager.get(current.uuid()) + .thenCompose(priorDelivery -> + this.applyStatusChange(current, status, priorDelivery)); + }); + } + + private CompletableFuture applyStatusChange( + Parcel current, + ParcelStatus status, + Optional priorDelivery + ) { + Parcel updated = withStatus(current, status); return this.parcelService.updateIfStatusWithinParcelOperation( - updated, parcel.status()).thenCompose(applied -> { + updated, current.status()).thenCompose(applied -> { if (!Boolean.TRUE.equals(applied)) { - return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + return parcelUnavailable(); } - if (status == parcel.status()) { - return CompletableFuture.completedFuture(EditResult.ok()); - } - return status == ParcelStatus.SENT ? this.armDelivery(updated) : this.disarmDelivery(updated); + CompletableFuture reconciliation = invoke( + () -> status == ParcelStatus.SENT + ? this.armDelivery(updated, priorDelivery) + : this.disarmDelivery(updated, priorDelivery)); + return reconciliation.handle((result, throwable) -> { + if (throwable == null) { + return CompletableFuture.completedFuture(result); + } + return this.compensateAndFail( + current, updated, priorDelivery, throwable); + }).thenCompose(future -> future); }); } - /** - * A SENT parcel must own a delivery row and a scheduled task, otherwise it would sit in SENT - * forever (e.g. after an admin flips a DELIVERED parcel back to SENT). Re-arm both. - */ - private CompletableFuture armDelivery(Parcel parcel) { - return this.deliveryManager.get(parcel.uuid()).thenCompose(existing -> { - if (existing.isPresent()) { - // A delivery row already exists; just make sure a task is armed for it. - this.scheduleSend(parcel, Duration.between(Instant.now(), existing.get().deliveryTimestamp())); - return CompletableFuture.completedFuture(EditResult.ok()); - } - Duration delay = parcel.priority() - ? this.config.settings.priorityParcelSendDuration - : this.config.settings.parcelSendDuration; - return this.deliveryManager.update(parcel.uuid(), Instant.now().plus(delay)) - .thenApply(delivery -> { - this.scheduleSend(parcel, delay); - return EditResult.ok(); - }); - }); + private CompletableFuture armDelivery( + Parcel parcel, + Optional priorDelivery + ) { + if (priorDelivery.isPresent()) { + this.scheduleSend( + parcel, + Duration.between( + Instant.now(), priorDelivery.get().deliveryTimestamp())); + return CompletableFuture.completedFuture(EditResult.ok()); + } + Duration delay = this.deliveryDelay(parcel.priority()); + return this.deliveryManager.create(parcel.uuid(), Instant.now().plus(delay)) + .thenApply(delivery -> { + this.scheduleSend(parcel, delay); + return EditResult.ok(); + }); } - /** A DELIVERED parcel keeps no pending delivery; drop any stray row so no task fires later. */ - private CompletableFuture disarmDelivery(Parcel parcel) { - return this.deliveryManager.get(parcel.uuid()).thenCompose(existing -> { - if (existing.isEmpty()) { - return CompletableFuture.completedFuture(EditResult.ok()); - } - return this.deliveryManager.delete(parcel.uuid()).thenApply(deleted -> EditResult.ok()); - }); + private CompletableFuture disarmDelivery( + Parcel parcel, + Optional priorDelivery + ) { + if (priorDelivery.isEmpty()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + return this.deliveryManager.delete(parcel.uuid()) + .thenCompose(deleted -> Boolean.TRUE.equals(deleted) + ? CompletableFuture.completedFuture(EditResult.ok()) + : CompletableFuture.failedFuture( + new IllegalStateException("Delivery delete returned false"))); } private void scheduleSend(Parcel parcel, Duration delay) { @@ -134,7 +160,8 @@ private void scheduleSend(Parcel parcel, Duration delay) { } public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { - return this.persist(withReceiver(parcel, receiver)); + return this.editAuthoritative( + parcel, current -> withReceiver(current, receiver)); } public CompletableFuture changeSize(Parcel parcel, ParcelSize newSize) { @@ -143,7 +170,8 @@ public CompletableFuture changeSize(Parcel parcel, ParcelSize newSiz if (itemCount > capacity(newSize)) { return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.SIZE_TOO_SMALL)); } - return this.persist(withSize(parcel, newSize)); + return this.editAuthoritative( + parcel, current -> withSize(current, newSize)); }); } @@ -152,7 +180,8 @@ public CompletableFuture changeDestination(Parcel parcel, UUID desti if (Boolean.TRUE.equals(full)) { return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.DESTINATION_FULL)); } - return this.persist(withDestination(parcel, destinationLocker)); + return this.editAuthoritative( + parcel, current -> withDestination(current, destinationLocker)); }); } @@ -165,35 +194,217 @@ private CompletableFuture changePriorityWithinParcelOperation( Parcel parcel, boolean newPriority ) { - Parcel updated = withPriority(parcel, newPriority); - return this.parcelService.updateWithinParcelOperation(updated).thenCompose(ignored -> { - if (parcel.status() != ParcelStatus.SENT || newPriority == parcel.priority()) { - return CompletableFuture.completedFuture(EditResult.ok()); - } - return this.deliveryManager.get(parcel.uuid()).thenCompose(optionalDelivery -> { - if (optionalDelivery.isEmpty()) { + return this.parcelService.getAuthoritativeWithinParcelOperation(parcel.uuid()) + .thenCompose(optional -> { + if (optional.isEmpty()) { + return parcelUnavailable(); + } + Parcel current = optional.get(); + if (newPriority == current.priority()) { return CompletableFuture.completedFuture(EditResult.ok()); } - Instant now = Instant.now(); - Instant shifted = shiftedDeliveryTimestamp( - optionalDelivery.get().deliveryTimestamp(), - parcel.priority(), newPriority, - this.config.settings.parcelSendDuration, - this.config.settings.priorityParcelSendDuration, - now); - return this.deliveryManager.update(parcel.uuid(), shifted) + return this.deliveryManager.get(current.uuid()) + .thenCompose(priorDelivery -> + this.applyPriorityChange(current, newPriority, priorDelivery)); + }); + } + + private CompletableFuture applyPriorityChange( + Parcel current, + boolean newPriority, + Optional priorDelivery + ) { + Parcel updated = withPriority(current, newPriority); + return this.parcelService.updateIfStatusWithinParcelOperation( + updated, current.status()).thenCompose(applied -> { + if (!Boolean.TRUE.equals(applied)) { + return parcelUnavailable(); + } + if (current.status() != ParcelStatus.SENT || priorDelivery.isEmpty()) { + return CompletableFuture.completedFuture(EditResult.ok()); + } + + Instant now = Instant.now(); + Instant shifted = shiftedDeliveryTimestamp( + priorDelivery.get().deliveryTimestamp(), + current.priority(), newPriority, + this.config.settings.parcelSendDuration, + this.config.settings.priorityParcelSendDuration, + now); + CompletableFuture reconciliation = invoke( + () -> this.deliveryManager.update(current.uuid(), shifted) .thenApply(ignoredDelivery -> { - // The task scheduled at send/startup still points at the old time; arm a fresh - // task at the new time so an earlier delivery actually fires earlier. The stale - // task self-heals at fire time: it aborts if the parcel is already delivered, - // or reschedules itself if it sees the later timestamp first. this.scheduleSend(updated, Duration.between(now, shifted)); return EditResult.ok(); - }); - }); + })); + return reconciliation.handle((result, throwable) -> { + if (throwable == null) { + return CompletableFuture.completedFuture(result); + } + return this.compensatePriorityAndFail( + current, priorDelivery.get(), throwable); + }).thenCompose(future -> future); + }); + } + + private CompletableFuture editAuthoritative( + Parcel parcel, + UnaryOperator edit + ) { + return this.parcelService.serializeParcelOperation( + parcel.uuid(), () -> this.parcelService + .getAuthoritativeWithinParcelOperation(parcel.uuid()) + .thenCompose(optional -> { + if (optional.isEmpty()) { + return parcelUnavailable(); + } + Parcel current = optional.get(); + Parcel updated = edit.apply(current); + return this.parcelService.updateIfStatusWithinParcelOperation( + updated, current.status()).thenApply(applied -> + Boolean.TRUE.equals(applied) + ? EditResult.ok() + : EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + })); + } + + private CompletableFuture compensateAndFail( + Parcel priorParcel, + Parcel changedParcel, + Optional priorDelivery, + Throwable trigger + ) { + Throwable failure = unwrap(trigger); + return this.attemptCompensation( + () -> this.parcelService.updateIfStatusWithinParcelOperation( + priorParcel, changedParcel.status()), + "Restore parcel " + priorParcel.uuid(), + failure + ) + .thenCompose(ignored -> this.restoreDelivery( + priorParcel.uuid(), priorDelivery, failure)) + .thenCompose(ignored -> this.rearmPriorDelivery( + priorParcel, priorDelivery, failure)) + .thenCompose(ignored -> CompletableFuture.failedFuture(failure)); + } + + private CompletableFuture compensatePriorityAndFail( + Parcel priorParcel, + Delivery priorDelivery, + Throwable trigger + ) { + Throwable failure = unwrap(trigger); + return this.attemptCompensation( + () -> this.parcelService.updateWithinParcelOperation(priorParcel), + "Restore parcel priority " + priorParcel.uuid(), + failure + ) + .thenCompose(ignored -> this.attemptCompensation( + () -> this.deliveryManager.update( + priorParcel.uuid(), priorDelivery.deliveryTimestamp()), + "Restore delivery " + priorParcel.uuid(), + failure + )) + .thenCompose(ignored -> this.rearmPriorDelivery( + priorParcel, Optional.of(priorDelivery), failure)) + .thenCompose(ignored -> CompletableFuture.failedFuture(failure)); + } + + private CompletableFuture restoreDelivery( + UUID parcel, + Optional priorDelivery, + Throwable failure + ) { + if (priorDelivery.isPresent()) { + return this.attemptCompensation( + () -> this.deliveryManager.update( + parcel, priorDelivery.get().deliveryTimestamp()), + "Restore delivery " + parcel, + failure); + } + return this.attemptCompensation( + () -> this.deliveryManager.delete(parcel), + "Delete created delivery " + parcel, + failure); + } + + private CompletableFuture rearmPriorDelivery( + Parcel priorParcel, + Optional priorDelivery, + Throwable failure + ) { + if (priorParcel.status() != ParcelStatus.SENT || priorDelivery.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + return this.attemptCompensation(() -> { + this.scheduleSend( + priorParcel, + Duration.between( + Instant.now(), priorDelivery.get().deliveryTimestamp())); + return CompletableFuture.completedFuture(null); + }, "Re-arm delivery task " + priorParcel.uuid(), failure); + } + + private CompletableFuture attemptCompensation( + Supplier> action, + String description, + Throwable failure + ) { + CompletableFuture result; + try { + result = action.get(); + } catch (Throwable throwable) { + failure.addSuppressed(unwrap(throwable)); + return CompletableFuture.completedFuture(null); + } + if (result == null) { + failure.addSuppressed( + new IllegalStateException(description + " returned a null future")); + return CompletableFuture.completedFuture(null); + } + return result.handle((value, throwable) -> { + if (throwable != null) { + failure.addSuppressed(unwrap(throwable)); + } else if (value instanceof Boolean completed && !completed) { + failure.addSuppressed( + new IllegalStateException(description + " returned false")); + } + return null; }); } + private Duration deliveryDelay(boolean priority) { + return priority + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + } + + private static CompletableFuture parcelUnavailable() { + return CompletableFuture.completedFuture( + EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof CompletionException + || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static CompletableFuture invoke( + Supplier> operation + ) { + try { + return java.util.Objects.requireNonNull( + operation.get(), "Admin parcel operation returned a null future"); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(unwrap(throwable)); + } + } + private static Parcel withName(Parcel p, String name) { return new Parcel(p.uuid(), p.sender(), name, p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); } diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index d405923a..9b5686b0 100644 --- a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -49,7 +49,7 @@ public class ParcelServiceImpl implements PluginParcelService { private static final long CACHE_EXPIRE_HOURS = 3; private static final long CACHE_MAX_SIZE = 10_000; - private static final Executor OPERATION_HANDOFF = command -> { + private static final Executor DEFAULT_OPERATION_HANDOFF = command -> { try { Thread.ofVirtual() .name("parcel-operation-handoff") @@ -69,6 +69,7 @@ public class ParcelServiceImpl implements PluginParcelService { private final PluginConfig config; private final Economy economy; private final Server server; + private final Executor operationHandoff; private final Cache parcelsByUuid; private final Map> parcelOperationTails = new HashMap<>(); @@ -85,6 +86,23 @@ public ParcelServiceImpl( PluginConfig config, Economy economy, Server server + ) { + this( + noticeService, parcelRepository, parcelContentRepository, + collectedParcelRepository, scheduler, config, economy, server, + DEFAULT_OPERATION_HANDOFF); + } + + ParcelServiceImpl( + NoticeService noticeService, + ParcelRepository parcelRepository, + ParcelContentRepository parcelContentRepository, + CollectedParcelRepository collectedParcelRepository, + Scheduler scheduler, + PluginConfig config, + Economy economy, + Server server, + Executor operationHandoff ) { this.noticeService = noticeService; this.parcelRepository = parcelRepository; @@ -94,6 +112,8 @@ public ParcelServiceImpl( this.config = config; this.economy = economy; this.server = server; + this.operationHandoff = Objects.requireNonNull( + operationHandoff, "Operation handoff cannot be null"); this.parcelsByUuid = Caffeine.newBuilder() .expireAfterAccess(CACHE_EXPIRE_HOURS, TimeUnit.HOURS) @@ -905,13 +925,23 @@ private CompletableFuture serializeAllParcelOperations( return result; } - private static void completeSerializedOperation( + private void completeSerializedOperation( CompletableFuture gate, CompletableFuture result, T value, Throwable throwable ) { - OPERATION_HANDOFF.execute(() -> gate.complete(null)); + Runnable releaseGate = () -> gate.complete(null); + try { + this.operationHandoff.execute(releaseGate); + } catch (Throwable handoffFailure) { + try { + CompletableFuture.runAsync(releaseGate); + } catch (Throwable emergencyHandoffFailure) { + handoffFailure.addSuppressed(emergencyHandoffFailure); + releaseGate.run(); + } + } if (throwable != null) { result.completeExceptionally(throwable); } else { diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java index 07757b72..5033bea3 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -2,11 +2,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -22,35 +26,270 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; 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 statusChangeUsesAuthoritativeStateInsteadOfStaleCollectedSnapshot() { + Parcel sent = parcel(ParcelStatus.SENT, false); + Parcel staleCollected = withStatus(sent, ParcelStatus.COLLECTED); + Parcel delivered = withStatus(sent, ParcelStatus.DELIVERED); + PluginParcelService parcelService = parcelServiceWithAuthoritative(sent); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + runSerializedOperationsImmediately( + parcelService, sent.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(deliveryManager.get(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + AdminParcelService service = service( + parcelService, deliveryManager, mock(Scheduler.class)); + + EditResult result = + service.changeStatus(staleCollected, ParcelStatus.DELIVERED).join(); + + assertEquals(EditResult.Status.OK, result.status()); + verify(parcelService).updateIfStatusWithinParcelOperation( + delivered, ParcelStatus.SENT); + } + + @Test + void fieldEditMergesOnlyChangedFieldIntoAuthoritativeCollectedParcel() { + Parcel stale = parcel(ParcelStatus.SENT, false); + Parcel authoritative = new Parcel( + stale.uuid(), UUID.randomUUID(), "current-name", "current-description", true, + UUID.randomUUID(), ParcelSize.LARGE, UUID.randomUUID(), UUID.randomUUID(), + ParcelStatus.COLLECTED); + Parcel expected = new Parcel( + authoritative.uuid(), authoritative.sender(), "edited-name", + authoritative.description(), authoritative.priority(), authoritative.receiver(), + authoritative.size(), authoritative.entryLocker(), + authoritative.destinationLocker(), ParcelStatus.COLLECTED); + PluginParcelService parcelService = mock(PluginParcelService.class); + runSerializedOperationsImmediately(parcelService, stale.uuid(), new AtomicBoolean()); + when(parcelService.getAuthoritativeWithinParcelOperation(stale.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(authoritative))); + when(parcelService.updateIfStatusWithinParcelOperation( + expected, ParcelStatus.COLLECTED)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(parcelService.update(any())).thenReturn(CompletableFuture.completedFuture(null)); + AdminParcelService service = service( + parcelService, mock(DeliveryManager.class), mock(Scheduler.class)); + + EditResult result = service.changeName(stale, "edited-name").join(); + + assertEquals(EditResult.Status.OK, result.status()); + verify(parcelService).updateIfStatusWithinParcelOperation( + expected, ParcelStatus.COLLECTED); + verify(parcelService, never()).update(any()); + } + + @Test + void statusChangeRollsBackParcelAndCreatedDeliveryWhenSchedulingIsRejected() { + Parcel delivered = parcel(ParcelStatus.DELIVERED, false); + Parcel sent = withStatus(delivered, ParcelStatus.SENT); + PluginParcelService parcelService = parcelServiceWithAuthoritative(delivered); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + runSerializedOperationsImmediately( + parcelService, delivered.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(deliveryManager.get(delivered.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + when(deliveryManager.create(eq(delivered.uuid()), any())) + .thenReturn(CompletableFuture.completedFuture( + new Delivery(delivered.uuid(), Instant.now()))); + when(deliveryManager.delete(delivered.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + doThrow(new IllegalStateException("scheduler rejected")) + .when(scheduler).runLaterAsync(any(), any()); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.changeStatus(delivered, ParcelStatus.SENT).join()); + + assertInstanceOf(IllegalStateException.class, failure.getCause()); + verify(parcelService).updateIfStatusWithinParcelOperation( + sent, ParcelStatus.DELIVERED); + verify(parcelService).updateIfStatusWithinParcelOperation( + delivered, ParcelStatus.SENT); + verify(deliveryManager).delete(delivered.uuid()); } @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); + void statusChangeCompensatesSynchronousDeliveryCreateFailure() { + Parcel delivered = parcel(ParcelStatus.DELIVERED, false); + Parcel sent = withStatus(delivered, ParcelStatus.SENT); + PluginParcelService parcelService = parcelServiceWithAuthoritative(delivered); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + runSerializedOperationsImmediately( + parcelService, delivered.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(deliveryManager.get(delivered.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + doThrow(new IllegalStateException("delivery create threw")) + .when(deliveryManager).create(eq(delivered.uuid()), any()); + when(deliveryManager.delete(delivered.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); - Parcel collected = collectedParcel(); - EditResult result = service.changeStatus(collected, ParcelStatus.SENT).join(); + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.changeStatus(delivered, ParcelStatus.SENT).join()); - assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); + assertEquals("delivery create threw", failure.getCause().getMessage()); + verify(parcelService).updateIfStatusWithinParcelOperation( + sent, ParcelStatus.DELIVERED); + verify(parcelService).updateIfStatusWithinParcelOperation( + delivered, ParcelStatus.SENT); + verify(deliveryManager).delete(delivered.uuid()); + } + + @Test + void statusChangeRestoresSentParcelDeliveryAndTaskWhenDeleteFails() { + Parcel sent = parcel(ParcelStatus.SENT, false); + Parcel delivered = withStatus(sent, ParcelStatus.DELIVERED); + Delivery priorDelivery = + new Delivery(sent.uuid(), Instant.now().plus(Duration.ofMinutes(5))); + PluginParcelService parcelService = parcelServiceWithAuthoritative(sent); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + runSerializedOperationsImmediately(parcelService, sent.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(deliveryManager.get(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(priorDelivery))); + when(deliveryManager.delete(sent.uuid())) + .thenReturn(CompletableFuture.failedFuture( + new IllegalStateException("delivery delete failed"))); + when(deliveryManager.update(sent.uuid(), priorDelivery.deliveryTimestamp())) + .thenReturn(CompletableFuture.completedFuture(priorDelivery)); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + assertThrows( + CompletionException.class, + () -> service.changeStatus(sent, ParcelStatus.DELIVERED).join()); + + verify(parcelService).updateIfStatusWithinParcelOperation( + delivered, ParcelStatus.SENT); + verify(parcelService).updateIfStatusWithinParcelOperation( + sent, ParcelStatus.DELIVERED); + verify(deliveryManager).update(sent.uuid(), priorDelivery.deliveryTimestamp()); + verify(scheduler).runLaterAsync(any(), any()); + } + + @Test + void statusChangeCompensatesWhenDeliveryDeleteReturnsFalse() { + Parcel sent = parcel(ParcelStatus.SENT, false); + Parcel delivered = withStatus(sent, ParcelStatus.DELIVERED); + Delivery priorDelivery = + new Delivery(sent.uuid(), Instant.now().plus(Duration.ofMinutes(5))); + PluginParcelService parcelService = parcelServiceWithAuthoritative(sent); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + runSerializedOperationsImmediately( + parcelService, sent.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(deliveryManager.get(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(priorDelivery))); + when(deliveryManager.delete(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(deliveryManager.update(sent.uuid(), priorDelivery.deliveryTimestamp())) + .thenReturn(CompletableFuture.completedFuture(priorDelivery)); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.changeStatus(sent, ParcelStatus.DELIVERED).join()); + + assertEquals("Delivery delete returned false", failure.getCause().getMessage()); + verify(parcelService).updateIfStatusWithinParcelOperation( + delivered, ParcelStatus.SENT); + verify(parcelService).updateIfStatusWithinParcelOperation( + sent, ParcelStatus.DELIVERED); + verify(deliveryManager).update(sent.uuid(), priorDelivery.deliveryTimestamp()); + verify(scheduler).runLaterAsync(any(), any()); + } + + @Test + void priorityChangeRestoresParcelDeliveryAndTaskWhenDeliveryUpdateFails() { + Parcel normal = parcel(ParcelStatus.SENT, false); + Parcel priority = withPriority(normal, true); + Delivery priorDelivery = + new Delivery(normal.uuid(), Instant.now().plus(Duration.ofMinutes(5))); + PluginParcelService parcelService = parcelServiceWithAuthoritative(normal); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + runSerializedOperationsImmediately(parcelService, normal.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(parcelService.updateWithinParcelOperation(normal)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(deliveryManager.get(normal.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(priorDelivery))); + when(deliveryManager.update(eq(normal.uuid()), any())) + .thenReturn( + CompletableFuture.failedFuture( + new IllegalStateException("delivery update failed")), + CompletableFuture.completedFuture(priorDelivery)); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + assertThrows( + CompletionException.class, + () -> service.changePriority(normal, true).join()); + + verify(parcelService).updateIfStatusWithinParcelOperation( + priority, ParcelStatus.SENT); + verify(parcelService).updateWithinParcelOperation(normal); + verify(deliveryManager, times(2)).update(eq(normal.uuid()), any()); + verify(scheduler).runLaterAsync(any(), any()); + } + + @Test + void compensationAggregatesParcelAndDeliveryRestoreFailures() { + Parcel delivered = parcel(ParcelStatus.DELIVERED, false); + PluginParcelService parcelService = parcelServiceWithAuthoritative(delivered); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + Scheduler scheduler = mock(Scheduler.class); + runSerializedOperationsImmediately( + parcelService, delivered.uuid(), new AtomicBoolean()); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn( + CompletableFuture.completedFuture(true), + CompletableFuture.failedFuture( + new IllegalStateException("parcel rollback failed"))); + when(deliveryManager.get(delivered.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + when(deliveryManager.create(eq(delivered.uuid()), any())) + .thenReturn(CompletableFuture.failedFuture( + new IllegalStateException("delivery create failed"))); + when(deliveryManager.delete(delivered.uuid())) + .thenReturn(CompletableFuture.failedFuture( + new IllegalStateException("delivery rollback failed"))); + AdminParcelService service = service(parcelService, deliveryManager, scheduler); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.changeStatus(delivered, ParcelStatus.SENT).join()); + + assertEquals("delivery create failed", failure.getCause().getMessage()); + assertEquals(2, failure.getCause().getSuppressed().length); } @Test void changeStatusKeepsParcelGateUntilDeliveryIsArmed() { Parcel delivered = parcel(ParcelStatus.DELIVERED, false); AtomicBoolean gateHeld = new AtomicBoolean(); - PluginParcelService parcelService = parcelServiceWithWithinStatusUpdate(); + PluginParcelService parcelService = parcelServiceWithWithinStatusUpdate(delivered); DeliveryManager deliveryManager = mock(DeliveryManager.class); Scheduler scheduler = mock(Scheduler.class); CompletableFuture deliveryUpdated = new CompletableFuture<>(); @@ -60,7 +299,7 @@ void changeStatusKeepsParcelGateUntilDeliveryIsArmed() { assertTrue(gateHeld.get(), "delivery read must run while the parcel gate is held"); return CompletableFuture.completedFuture(Optional.empty()); }); - when(deliveryManager.update(eq(delivered.uuid()), any())).thenAnswer(invocation -> { + when(deliveryManager.create(eq(delivered.uuid()), any())).thenAnswer(invocation -> { assertTrue(gateHeld.get(), "delivery write must run while the parcel gate is held"); return deliveryUpdated; }); @@ -93,8 +332,10 @@ void changePriorityKeepsParcelGateUntilDeliveryAndSchedulingComplete() { Delivery current = new Delivery(sent.uuid(), Instant.now().plus(Duration.ofMinutes(10))); runSerializedOperationsImmediately(parcelService, sent.uuid(), gateHeld); - when(parcelService.updateWithinParcelOperation(any())) - .thenReturn(CompletableFuture.completedFuture(null)); + when(parcelService.getAuthoritativeWithinParcelOperation(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(sent))); + when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); when(deliveryManager.get(sent.uuid())).thenAnswer(invocation -> { assertTrue(gateHeld.get(), "delivery read must run while the parcel gate is held"); return CompletableFuture.completedFuture(Optional.of(current)); @@ -117,7 +358,8 @@ void changePriorityKeepsParcelGateUntilDeliveryAndSchedulingComplete() { verify(scheduler).runLaterAsync(any(), any()); verify(parcelService).serializeParcelOperation(eq(sent.uuid()), any()); verify(parcelService, never()).update(any()); - verify(parcelService).updateWithinParcelOperation(any()); + verify(parcelService).updateIfStatusWithinParcelOperation( + any(), eq(ParcelStatus.SENT)); } @Test @@ -178,13 +420,22 @@ void unchangedPriorityKeepsTimestamp() { assertEquals(oldTs, shifted); } - private static PluginParcelService parcelServiceWithWithinStatusUpdate() { + private static PluginParcelService parcelServiceWithWithinStatusUpdate(Parcel parcel) { PluginParcelService parcelService = mock(PluginParcelService.class); + when(parcelService.getAuthoritativeWithinParcelOperation(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(parcel))); when(parcelService.updateIfStatusWithinParcelOperation(any(), any())) .thenReturn(CompletableFuture.completedFuture(true)); return parcelService; } + private static PluginParcelService parcelServiceWithAuthoritative(Parcel parcel) { + PluginParcelService parcelService = mock(PluginParcelService.class); + when(parcelService.getAuthoritativeWithinParcelOperation(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(parcel))); + return parcelService; + } + private static void runSerializedOperationsImmediately( PluginParcelService parcelService, UUID parcelId, @@ -213,4 +464,18 @@ private static Parcel parcel(ParcelStatus status, boolean priority) { return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "name", "description", priority, UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); } + + 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); + } + + private static Parcel withPriority(Parcel parcel, boolean priority) { + return new Parcel( + parcel.uuid(), parcel.sender(), parcel.name(), parcel.description(), priority, + parcel.receiver(), parcel.size(), parcel.entryLocker(), + parcel.destinationLocker(), parcel.status()); + } } diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java index 1ea6f97c..94906e2b 100644 --- a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -45,6 +45,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicBoolean; @@ -63,6 +64,22 @@ class ParcelServiceImplTest { + @Test + void rejectedOperationHandoffStillCompletesResultAndReleasesGate() + throws Exception { + Executor rejectingHandoff = command -> { + throw new IllegalStateException("handoff rejected"); + }; + OperationFixture fixture = new OperationFixture(rejectingHandoff); + + CompletableFuture first = fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> CompletableFuture.completedFuture(null)); + CompletableFuture second = fixture.service.serializeParcelOperation( + fixture.parcel.uuid(), () -> CompletableFuture.completedFuture(null)); + + CompletableFuture.allOf(first, second).get(2, TimeUnit.SECONDS); + } + @Test void outwardCompletionCallbackCanQueueAndAwaitFollowingMutation() { OperationFixture fixture = new OperationFixture(); @@ -925,13 +942,22 @@ private static final class OperationFixture { private final ParcelServiceImpl service; private OperationFixture() { + this(null); + } + + private OperationFixture(Executor operationHandoff) { when(this.server.getPluginManager()).thenReturn(this.pluginManager); when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); when(this.sender.hasPermission("parcellockers.fee.bypass")).thenReturn(true); when(this.items.getFirst().clone()).thenReturn(this.items.getFirst()); - this.service = new ParcelServiceImpl( - this.noticeService, this.parcelRepository, this.contentRepository, - this.collectedRepository, this.scheduler, this.config, this.economy, this.server); + this.service = operationHandoff == null + ? new ParcelServiceImpl( + this.noticeService, this.parcelRepository, this.contentRepository, + this.collectedRepository, this.scheduler, this.config, this.economy, this.server) + : new ParcelServiceImpl( + this.noticeService, this.parcelRepository, this.contentRepository, + this.collectedRepository, this.scheduler, this.config, this.economy, this.server, + operationHandoff); } } }