diff --git a/.gitignore b/.gitignore index 2fcfe81ad..1158d10c8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ .codex/ .gemini/ graphify-out/ +.worktrees/ .DS_Store [Dd]esktop.ini diff --git a/build.gradle.kts b/build.gradle.kts index dafefaf1d..b515ddd73 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,211 +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.1" - id("com.modrinth.minotaur") version "2.+" -} - -group = "com.eternalcode" -version = "0.5.1-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.1" apply false + id("com.modrinth.minotaur") version "2.+" apply false +} + +allprojects { + group = "com.eternalcode" + version = "0.5.1-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.ADOPTIUM - 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("bukkit", "paper", "purpur") - syncBodyFrom = rootProject.file("README.md").readText() -} - -tasks { - runServer { - minecraftVersion("26.2") - downloadPlugins { - modrinth("luckperms", "v5.5.53-bukkit") - modrinth("vaultunlocked", "2.20.2") - modrinth("essentialsx", "2.22.0") -// modrinth("discordsrv", "1.30.4") // uncomment to test with DiscordSRV integration - } - jvmArgs( - "-Dcom.mojang.eula.agree=true", - "-Xlog:aot=info" - ) -// jvmArgs("-XX:AOTCacheOutput=server.aot") - jvmArgs("-XX:AOTCache=server.aot") + 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 a02aabee7..396614cf8 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/docs/superpowers/plans/2026-07-20-api-module.md b/docs/superpowers/plans/2026-07-20-api-module.md new file mode 100644 index 000000000..bb71d49cd --- /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. 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 000000000..63e7860c6 --- /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. diff --git a/parcellockers-api/build.gradle.kts b/parcellockers-api/build.gradle.kts new file mode 100644 index 000000000..3d9a41e65 --- /dev/null +++ b/parcellockers-api/build.gradle.kts @@ -0,0 +1,40 @@ +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("io.papermc.paper:paper-api:${Versions.PAPER_API}") + 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") + } + } + } +} 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 000000000..a11adbf45 --- /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 000000000..066787ed5 --- /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/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 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 000000000..1ab869040 --- /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/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 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 014670d16..1a89ab332 100644 --- a/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/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 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 281dfc4cd..8c02fe847 100644 --- a/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 000000000..46e31349c --- /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 000000000..aa90c6449 --- /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 000000000..5666a0fbe --- /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/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java similarity index 78% rename from src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java index c248c7ed4..d083ee5e0 100644 --- a/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/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 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/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 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/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 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 45fbc8ff0..5bd15c52c 100644 --- a/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/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 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 dd8a98556..da40b3da9 100644 --- a/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/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 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 c388b0d4d..53935fc94 100644 --- a/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/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 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 68395516c..703523187 100644 --- a/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 000000000..35bead0de --- /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 000000000..1fb19dcf9 --- /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/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 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 26857b40c..c39b8176d 100644 --- a/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 000000000..c29a25b06 --- /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/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 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/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 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/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 src/main/java/com/eternalcode/parcellockers/shared/Position.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/shared/Position.java index 6a43f3b56..77a5e978f 100644 --- a/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/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 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 000000000..728e87ee9 --- /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/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 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/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 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/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 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 000000000..b2a6fee37 --- /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 000000000..0d04e5112 --- /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 000000000..c6d0843f0 --- /dev/null +++ b/parcellockers-api/src/test/java/com/eternalcode/parcellockers/ApiSurfaceTest.java @@ -0,0 +1,56 @@ +package com.eternalcode.parcellockers; + +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 { + + @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)); + } + + @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) + .collect(Collectors.toSet()); + } +} 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 000000000..b14ebe531 --- /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/build.gradle.kts b/parcellockers-plugin/build.gradle.kts new file mode 100644 index 000000000..d1823613a --- /dev/null +++ b/parcellockers-plugin/build.gradle.kts @@ -0,0 +1,181 @@ +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.ADOPTIUM + 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("bukkit", "paper", "purpur") + syncBodyFrom = rootProject.file("README.md").readText() +} + +tasks { + runServer { + minecraftVersion("26.2") + downloadPlugins { + modrinth("luckperms", "v5.5.53-bukkit") + modrinth("vaultunlocked", "2.20.2") + modrinth("essentialsx", "2.22.0") +// modrinth("discordsrv", "1.30.4") // uncomment to test with DiscordSRV integration + } + jvmArgs( + "-Dcom.mojang.eula.agree=true", + "-Xlog:aot=info" + ) +// jvmArgs("-XX:AOTCacheOutput=server.aot") + jvmArgs("-XX:AOTCache=server.aot") + } + + 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 93% rename from src/main/java/com/eternalcode/parcellockers/ParcelLockers.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 12ef2ba9b..028a133a1 100644 --- a/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; @@ -43,6 +44,7 @@ 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; @@ -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() { @@ -134,7 +140,7 @@ public void onEnable() { new ParcelReturnRepositoryOrmLite(databaseManager, scheduler); // service and managers - ParcelService parcelService = new ParcelServiceImpl( + PluginParcelService parcelService = new ParcelServiceImpl( noticeService, parcelRepository, parcelContentRepository, @@ -149,6 +155,7 @@ 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.lockerService = lockerManager; ParcelContentManager parcelContentManager = new ParcelContentManager(parcelContentRepository); ItemStorageManager itemStorageManager = new ItemStorageManager(itemStorageRepository, server); DeliveryManager deliveryManager = new DeliveryManager(deliveryRepository); @@ -162,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); @@ -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; 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/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 000000000..d46a222f7 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java @@ -0,0 +1,232 @@ +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 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; + +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) { + if (this.delegate.isParcelOperationCallbackActive()) { + return callbackMutationFailure(); + } + try { + 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) { + return CompletableFuture.failedFuture( + operationFailure("Failed to snapshot parcel contents", throwable)); + } + } + + @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) { + if (this.delegate.isParcelOperationCallbackActive()) { + return callbackMutationFailure(); + } + 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 + 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); + } + + 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 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(); + } + return throwable; + } +} 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 96% 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 index 9366d2845..88db816b2 100644 --- a/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/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 89% rename from src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java index 265136f52..144df31e7 100644 --- a/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/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 97% rename from src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 54666dc98..1099550ec 100644 --- a/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/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/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java new file mode 100644 index 000000000..33b684b66 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -0,0 +1,330 @@ +package com.eternalcode.parcellockers.itemstorage; + +import com.eternalcode.parcellockers.itemstorage.event.ItemStorageUpdateEvent; +import com.eternalcode.parcellockers.itemstorage.repository.ItemStorageRepository; +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 java.util.function.Supplier; +import org.bukkit.Server; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; + +public class ItemStorageManager { + + private final Cache cache; + + private final ItemStorageRepository itemStorageRepository; + private final Server server; + private final Object reservationLock = new Object(); + private final Map reservations = new HashMap<>(); + private final Map ordinaryOperations = new HashMap<>(); + private boolean globalOperation; + + public ItemStorageManager(ItemStorageRepository itemStorageRepository, Server server) { + this.itemStorageRepository = itemStorageRepository; + this.server = server; + + this.cache = Caffeine.newBuilder() + .expireAfterWrite(6, TimeUnit.HOURS) + .maximumSize(10_000) + .build(); + + this.cacheAll(); + } + + public CompletableFuture> get(UUID parcelId) { + 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) { + 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) { + return this.runOrdinaryOperation(owner, () -> this.createInternal(owner, items, true)); + } + + private CompletableFuture createInternal( + UUID owner, + List items, + boolean fireEvent + ) { + ItemStorage oldItemStorage = this.cache.getIfPresent(owner); + ItemStorage newItemStorage = new ItemStorage(owner, items); + + 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")); + } + } + + this.cache.put(owner, newItemStorage); + // Return the save future so callers can react to a persistence failure instead of losing items. + // If the save fails, undo the optimistic cache update so the cache never holds an unpersisted + // storage (which, combined with re-giving the items to the player, would duplicate them). + return this.itemStorageRepository.save(newItemStorage) + .whenComplete((ignored, throwable) -> { + if (throwable != null) { + if (oldItemStorage != null) { + this.cache.put(owner, oldItemStorage); + } else { + this.cache.invalidate(owner); + } + } + }) + .thenApply(ignored -> newItemStorage); + } + + private void cacheAll() { + this.runGlobalOperation(() -> this.itemStorageRepository.fetchAll() + .thenAccept(all -> all.ifPresent(list -> list.forEach(itemStorage -> this.cache.put( + itemStorage.owner(), + itemStorage))))); + } + + public CompletableFuture delete(UUID owner) { + return this.runOrdinaryOperation(owner, () -> this.deleteInternal(owner)); + } + + public Optional reserve(UUID owner) { + synchronized (this.reservationLock) { + if (this.globalOperation + || this.reservations.containsKey(owner) + || this.ordinaryOperations.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) { + return this.runReservedOperation( + reservation, + () -> this.deleteInternal(reservation.owner())); + } + + private CompletableFuture restoreReserved( + Reservation reservation, + List items + ) { + return this.runReservedOperation( + reservation, + () -> 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 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.globalOperation + || this.reservations.containsKey(owner) + || this.ordinaryOperations.containsKey(owner)) { + return false; + } + this.ordinaryOperations.put(owner, 1); + return true; + } + } + + private void endOrdinaryOperation(UUID owner) { + synchronized (this.reservationLock) { + int remaining = this.ordinaryOperations.getOrDefault(owner, 0) - 1; + if (remaining > 0) { + this.ordinaryOperations.put(owner, remaining); + } else { + this.ordinaryOperations.remove(owner); + } + } + } + + 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() + || reservation.activeOperations > 0) { + return false; + } + reservation.activeOperations++; + return true; + } + } + + private void endReservedOperation(Reservation reservation) { + synchronized (this.reservationLock) { + reservation.activeOperations--; + if (reservation.activeOperations == 0 && reservation.closed.get()) { + this.reservations.remove(reservation.owner(), reservation); + } + } + } + + private void requestRelease(Reservation reservation) { + synchronized (this.reservationLock) { + 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.runGlobalOperation(() -> this.itemStorageRepository.deleteAll() + .thenAccept(deleted -> { + this.cache.invalidateAll(); + + noticeService.create() + .viewer(sender) + .notice(messages -> messages.admin.deletedItemStorages) + .placeholder("{COUNT}", deleted.toString()) + .send(); + })); + } + + 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; + } + + @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.requestRelease(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 000000000..9e0c8ad94 --- /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/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/LockerManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java similarity index 95% rename from src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/LockerManager.java index 05e5f8147..8ea681a61 100644 --- a/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,11 +213,17 @@ 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(); @@ -226,6 +237,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/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/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/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/repository/ParcelRepository.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java similarity index 79% 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 index 81713f214..c2fbfbc54 100644 --- a/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/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 69% 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 index a459542da..b613e7147 100644 --- a/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/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/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 new file mode 100644 index 000000000..58607c6d5 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -0,0 +1,435 @@ +package com.eternalcode.parcellockers.parcel.service; + +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; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +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 { + + 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(PluginParcelService parcelService, ParcelContentManager parcelContentManager, + DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config, Scheduler scheduler) { + this.parcelService = parcelService; + this.parcelContentManager = parcelContentManager; + this.deliveryManager = deliveryManager; + this.lockerManager = lockerManager; + this.config = config; + this.scheduler = scheduler; + } + + public static int capacity(ParcelSize size) { + return switch (size) { + case SMALL -> 9; + case MEDIUM -> 18; + case LARGE -> 27; + }; + } + + /** Pure delta-shift helper, clamped to never be before {@code now}. */ + public static Instant shiftedDeliveryTimestamp(Instant oldTimestamp, boolean oldPriority, boolean newPriority, + Duration normalDuration, Duration priorityDuration, Instant now) { + Duration oldDuration = oldPriority ? priorityDuration : normalDuration; + Duration newDuration = newPriority ? priorityDuration : normalDuration; + Instant shifted = oldTimestamp.plus(newDuration).minus(oldDuration); + return shifted.isBefore(now) ? now : shifted; + } + + public CompletableFuture changeName(Parcel parcel, String name) { + return this.editAuthoritative(parcel, current -> withName(current, name)); + } + + public CompletableFuture changeDescription(Parcel parcel, String description) { + return this.editAuthoritative( + parcel, current -> withDescription(current, description)); + } + + /** + * A COLLECTED parcel's content row is the collection snapshot, not a live shipment; flipping + * its status (e.g. back to SENT) would arm a delivery and hand the receiver the same items + * a second time. Refuse the change outright — a return is the only supported transition out + * of COLLECTED. + */ + public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { + return this.parcelService.serializeParcelOperation( + parcel.uuid(), () -> this.changeStatusWithinParcelOperation(parcel, status)); + } + + private CompletableFuture changeStatusWithinParcelOperation( + Parcel parcel, + ParcelStatus status + ) { + 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, current.status()).thenCompose(applied -> { + if (!Boolean.TRUE.equals(applied)) { + return parcelUnavailable(); + } + 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); + }); + } + + 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(); + }); + } + + 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) { + this.scheduler.runLaterAsync( + new ParcelSendTask(parcel, this.parcelService, this.deliveryManager, this.scheduler), + delay.isNegative() ? Duration.ZERO : delay); + } + + public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { + return this.editAuthoritative( + parcel, current -> withReceiver(current, receiver)); + } + + public CompletableFuture changeSize(Parcel parcel, ParcelSize newSize) { + return this.parcelContentManager.get(parcel.uuid()).thenCompose(optional -> { + int itemCount = optional.map(content -> content.items().size()).orElse(0); + if (itemCount > capacity(newSize)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.SIZE_TOO_SMALL)); + } + return this.editAuthoritative( + parcel, current -> withSize(current, newSize)); + }); + } + + public CompletableFuture changeDestination(Parcel parcel, UUID destinationLocker) { + return this.lockerManager.isLockerFull(destinationLocker).thenCompose(full -> { + if (Boolean.TRUE.equals(full)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.DESTINATION_FULL)); + } + return this.editAuthoritative( + parcel, current -> withDestination(current, destinationLocker)); + }); + } + + public CompletableFuture changePriority(Parcel parcel, boolean newPriority) { + return this.parcelService.serializeParcelOperation( + parcel.uuid(), () -> this.changePriorityWithinParcelOperation(parcel, newPriority)); + } + + private CompletableFuture changePriorityWithinParcelOperation( + Parcel parcel, + boolean newPriority + ) { + 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()); + } + 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 -> { + 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()); + } + + private static Parcel withDescription(Parcel p, String description) { + return new Parcel(p.uuid(), p.sender(), p.name(), description, p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withPriority(Parcel p, boolean priority) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), priority, p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withSize(Parcel p, ParcelSize size) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), size, p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withStatus(Parcel p, ParcelStatus status) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), status); + } + + private static Parcel withReceiver(Parcel p, UUID receiver) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), receiver, p.size(), p.entryLocker(), p.destinationLocker(), p.status()); + } + + private static Parcel withDestination(Parcel p, UUID destinationLocker) { + return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), destinationLocker, p.status()); + } +} 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/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 new file mode 100644 index 000000000..6ee04689d --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -0,0 +1,362 @@ +package com.eternalcode.parcellockers.parcel.service; + +import com.eternalcode.commons.scheduler.Scheduler; +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; +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.Level; +import java.util.logging.Logger; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class ParcelDispatchService { + + private static final Logger LOGGER = Logger.getLogger(ParcelDispatchService.class.getName()); + + private final LockerManager lockerManager; + private final PluginParcelService parcelService; + private final DeliveryManager deliveryManager; + private final ItemStorageManager itemStorageManager; + private final Scheduler scheduler; + private final PluginConfig config; + private final NoticeService noticeService; + + // 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<>(); + + public ParcelDispatchService( + LockerManager lockerManager, + PluginParcelService parcelService, + DeliveryManager deliveryManager, + ItemStorageManager itemStorageManager, + Scheduler scheduler, + PluginConfig config, + NoticeService noticeService + ) { + this.lockerManager = lockerManager; + this.parcelService = parcelService; + this.deliveryManager = deliveryManager; + this.itemStorageManager = itemStorageManager; + this.scheduler = scheduler; + this.config = config; + this.noticeService = noticeService; + } + + 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); + if (reservation == null) { + this.notifyCannotSend(sender); + return CompletableFuture.completedFuture(false); + } + + 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(this.operationFailure( + "Failed to start dispatch for parcel " + parcel.uuid(), + throwable + )); + } + } + + private CompletableFuture dispatchInternal( + Player sender, + Parcel parcel, + List items, + ItemStorageReservation reservation + ) { + return this.lockerManager.isLockerFull(parcel.destinationLocker()) + .thenCompose(isFull -> { + if (isFull) { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.lockerFull); + return CompletableFuture.completedFuture(false); + } + + Duration delay = parcel.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + + return this.parcelService.sendWithinParcelOperation(sender, parcel, items) + .thenCompose(success -> { + if (!Boolean.TRUE.equals(success)) { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + return CompletableFuture.completedFuture(false); + } + + 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)) { + 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); + }) + .thenCompose(Function.identity()); + }); + }) + .handle((result, throwable) -> { + if (throwable == null) { + return CompletableFuture.completedFuture(result); + } + 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 CompletableFuture.failedFuture(failure); + }) + .thenCompose(Function.identity()); + } + + private CompletableFuture createDelivery( + Player sender, + Parcel parcel, + List items, + Duration delay, + ItemStorageReservation reservation + ) { + 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.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()); + } + + private CompletableFuture scheduleDelivery( + Player sender, + Parcel parcel, + List items, + Duration delay, + ItemStorageReservation reservation + ) { + ParcelSendTask task = new ParcelSendTask( + parcel, + this.parcelService, + this.deliveryManager, + this.scheduler + ); + + try { + this.scheduler.runLaterAsync(task, delay); + } catch (Throwable throwable) { + 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 { + 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 compensate( + Player sender, + Parcel parcel, + String message, + Throwable trigger, + List steps + ) { + 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 deleteStorage( + ItemStorageReservation reservation + ) { + 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 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 CleanupStep restoreStep( + ItemStorageReservation reservation, + List items + ) { + return new CleanupStep( + "Restore sender storage " + reservation.owner(), + () -> reservation.restore(items) + ); + } + + private CleanupStep rollbackStep(Player sender, Parcel parcel) { + return new CleanupStep( + "Roll back parcel " + parcel.uuid(), + () -> this.parcelService.rollbackSendWithinParcelOperation(sender, parcel) + ); + } + + 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 ParcelOperationException operationFailure(String message, Throwable throwable) { + Throwable cause = unwrap(throwable); + if (cause instanceof ParcelOperationException operationException) { + return operationException; + } + return new ParcelOperationException(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 new file mode 100644 index 000000000..9b5686b04 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -0,0 +1,983 @@ +package com.eternalcode.parcellockers.parcel.service; + +import static com.eternalcode.parcellockers.util.InventoryUtil.canHold; + +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; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.event.ParcelCollectEvent; +import com.eternalcode.parcellockers.parcel.event.ParcelSendEvent; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.returns.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.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.Executor; +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; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class ParcelServiceImpl implements PluginParcelService { + + private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; + private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; + private static final String PLACEHOLDER_COUNT = "{COUNT}"; + + private static final long CACHE_EXPIRE_HOURS = 3; + private static final long CACHE_MAX_SIZE = 10_000; + private static final Executor DEFAULT_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; + private final ParcelContentRepository parcelContentRepository; + private final CollectedParcelRepository collectedParcelRepository; + private final Scheduler scheduler; + 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<>(); + private CompletableFuture globalParcelOperationTail = + CompletableFuture.completedFuture(null); + private final ThreadLocal parcelOperationCallback = new ThreadLocal<>(); + + public ParcelServiceImpl( + NoticeService noticeService, + ParcelRepository parcelRepository, + ParcelContentRepository parcelContentRepository, + CollectedParcelRepository collectedParcelRepository, + Scheduler scheduler, + 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; + this.parcelContentRepository = parcelContentRepository; + this.collectedParcelRepository = collectedParcelRepository; + this.scheduler = scheduler; + 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) + .maximumSize(CACHE_MAX_SIZE) + .build(); + } + + @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.runParcelOperationCallback( + parcel.uuid(), () -> this.server.getPluginManager().callEvent(event)); + + if (event.isCancelled()) { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + return CompletableFuture.completedFuture(false); + } + + List itemsCopy = items.stream() + .map(ItemStack::clone) + .toList(); + + try { + return this.parcelRepository.saveIfAbsent(parcel) + .thenCompose(inserted -> { + 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))); + } + } + + 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); + } + + chargedFee = fee; + this.noticeService.create() + .notice(messages -> messages.parcel.feeWithdrawn) + .player(sender.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, formattedFee) + .send(); + } + } + + double refundableFee = chargedFee; + return this.parcelContentRepository.save(new ParcelContent(parcel.uuid(), items)) + .thenApply(contentSaved -> { + this.parcelsByUuid.put(parcel.uuid(), parcel); + return true; + }) + .exceptionallyCompose(throwable -> this.compensatePersistenceFailure( + sender, parcel, refundableFee, unwrap(throwable))); + } catch (Throwable 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. + } + } + + 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 + ) + .thenCompose(ignored -> this.attemptRollback( + () -> this.parcelContentRepository.delete(parcel.uuid()), + "Delete parcel content " + parcel.uuid(), + cleanupFailures + )) + .thenCompose(ignored -> this.attemptRollback(() -> { + this.refundFee(sender, refundableFee); + return CompletableFuture.completedFuture(null); + }, "Refund parcel fee for " + parcel.uuid(), cleanupFailures)) + .thenCompose(ignored -> { + cleanupFailures.forEach(failure::addSuppressed); + this.notifyCannotSend(sender); + return CompletableFuture.failedFuture(failure); + }); + } + + @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"); + + List failures = new ArrayList<>(); + try { + if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { + this.refundFee(sender, this.feeFor(parcel.size())); + } + } catch (Throwable throwable) { + failures.add(unwrap(throwable)); + } + this.parcelsByUuid.invalidate(parcel.uuid()); + + 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) { + return switch (size) { + case SMALL -> this.config.settings.smallParcelFee; + case MEDIUM -> this.config.settings.mediumParcelFee; + case LARGE -> this.config.settings.largeParcelFee; + }; + } + + private void refundFee(Player sender, double fee) { + if (fee > 0) { + 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()); + } + } + } + + @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); + } + + @Override + public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { + Objects.requireNonNull(updated, "Updated parcel cannot be null"); + Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); + return this.serializeParcelOperation( + updated.uuid(), + () -> this.updateIfStatusWithinParcelOperation(updated, expectedStatus)); + } + + @Override + public CompletableFuture updateIfStatusWithinParcelOperation( + Parcel updated, + ParcelStatus expectedStatus + ) { + return this.parcelRepository.updateIfStatus(updated, expectedStatus).thenApply(applied -> { + if (applied) { + this.parcelsByUuid.put(updated.uuid(), updated); + } else { + this.parcelsByUuid.invalidate(updated.uuid()); + } + return applied; + }); + } + + @Override + public CompletableFuture delete(CommandSender sender, Parcel parcel) { + Objects.requireNonNull(sender, "Sender cannot be null"); + 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() + .notice(messages -> messages.parcel.deleted) + .viewer(sender) + .send(); + this.parcelsByUuid.invalidate(parcel.uuid()); + }) + .exceptionally(throwable -> { + this.noticeService.create() + .notice(messages -> messages.parcel.cannotDelete) + .viewer(sender) + .send(); + return null; + }); + } + + @Override + 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 { + 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.notifyCannotCollect(playerId); + return CompletableFuture.completedFuture(null); + } + + return this.parcelContentRepository.find(parcel.uuid()).thenCompose(optional -> { + if (optional.isEmpty()) { + this.notifyCannotCollect(playerId); + return CompletableFuture.completedFuture(null); + } + + List items = optional.get().items(); + CompletableFuture result = new CompletableFuture<>(); + this.scheduleCollection(player, parcel, items, result); + return result; + }); + }); + } + + 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.notifyNoInventorySpace(playerId); + result.complete(null); + return; + } + + 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)) { + this.notifyCannotCollect(playerId); + result.complete(null); + return; + } + + this.parcelsByUuid.put( + 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))); + } + } + + 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 { + ParcelCollectEvent event = new ParcelCollectEvent(parcel); + this.runParcelOperationCallback( + parcel.uuid(), () -> 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.runParcelOperationCallback( + parcel.uuid(), () -> 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(), + parcel.destinationLocker(), status); + } + + @Override + public CompletableFuture> get(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + + Parcel cached = this.parcelsByUuid.getIfPresent(uuid); + if (cached != null) { + return CompletableFuture.completedFuture(Optional.of(cached)); + } + return this.parcelRepository.findById(uuid).thenApply(optional -> { + optional.ifPresent(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return optional; + }); + } + + @Override + public void invalidate(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + this.parcelsByUuid.invalidate(uuid); + } + + @Override + public CompletableFuture> getBySender(UUID sender, Page page) { + Objects.requireNonNull(sender, "Sender UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findBySender(sender, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + + @Override + public CompletableFuture> getByReceiver(UUID receiver, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findByReceiver(receiver, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + + @Override + public CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findCollectible(receiver, destinationLocker, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + + @Override + public CompletableFuture> getReturnable(UUID receiver, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findReturnable(receiver, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + + @Override + public CompletableFuture> getAll(Page page) { + Objects.requireNonNull(page, "Page cannot be null"); + return this.parcelRepository.findPage(page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + + @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); + } + this.parcelsByUuid.invalidate(uuid); + // The parcel row is gone, so reclaim its content row to avoid an orphaned leak. + // A failed content delete only leaves an orphaned row (logged); it never affects + // the already-deleted parcel, so the operation still reports success. + return this.parcelContentRepository.delete(uuid) + .exceptionally(throwable -> { + this.server.getLogger().warning("Failed to delete content for deleted parcel " + + uuid + ": " + throwable.getMessage()); + return false; + }) + .thenApply(contentDeleted -> true); + }); + } + + @Override + public CompletableFuture delete(Parcel parcel) { + Objects.requireNonNull(parcel, "Parcel cannot be null"); + 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) { + completeSerializedOperation(gate, result, null, throwable); + return; + } + running.whenComplete((value, throwable) -> + completeSerializedOperation(gate, result, value, throwable)); + }); + 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); + } + } + } + + @Override + public boolean isParcelOperationCallbackActive() { + return this.parcelOperationCallback.get() != null; + } + + 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) { + completeSerializedOperation(gate, result, null, throwable); + return; + } + running.whenComplete((value, throwable) -> + completeSerializedOperation(gate, result, value, throwable)); + }); + gate.whenComplete((ignored, throwable) -> { + synchronized (this.parcelOperationTails) { + if (this.globalParcelOperationTail == gate) { + this.globalParcelOperationTail = CompletableFuture.completedFuture(null); + } + } + }); + return result; + } + + private void completeSerializedOperation( + CompletableFuture gate, + CompletableFuture result, + T value, + Throwable throwable + ) { + 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 { + result.complete(value); + } + } + + @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) + .viewer(sender) + .placeholder(PLACEHOLDER_COUNT, deleted.toString()) + .send(); + + this.parcelsByUuid.invalidateAll(); + + // Reclaim every content row alongside the parcels so a bulk delete leaves nothing orphaned. + return this.parcelContentRepository.deleteAll() + .exceptionally(throwable -> { + this.server.getLogger().warning("Failed to delete parcel contents during bulk delete: " + + throwable.getMessage()); + return 0; + }) + .thenAccept(contentDeleted -> {}); + }); + } +} 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 000000000..73c7f7c80 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/PluginParcelService.java @@ -0,0 +1,50 @@ +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 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 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); + + CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService); +} 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 66% 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 index d06f9d757..5d6664753 100644 --- a/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/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 98% rename from src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java index daf553752..60c2d13c9 100644 --- a/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/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 87% 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 index db0418746..a367786af 100644 --- a/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/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/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/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/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/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 000000000..aa04d7d34 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java @@ -0,0 +1,283 @@ +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; +import static org.mockito.Mockito.when; + +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.ParcelServiceImpl; +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; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +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(); + 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() { + Fixture fixture = new Fixture(); + CompletableFuture dispatched = new CompletableFuture<>(); + when(fixture.dispatcher.dispatch( + fixture.sender, fixture.parcel, fixture.items)).thenReturn(dispatched); + + CompletableFuture result = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + + assertFalse(result.isDone()); + verify(fixture.dispatcher, never()).dispatch(any(), any(), any()); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runAsync(task.capture()); + task.getValue().run(); + + verify(fixture.dispatcher).dispatch( + fixture.sender, fixture.parcel, fixture.items); + dispatched.complete(true); + assertSame(Boolean.TRUE, result.join()); + verify(fixture.delegate, never()).send(any(), any(), any()); + } + + @Test + void schedulerSubmissionFailureCompletesFutureExceptionallyWithoutSynchronousThrow() { + Fixture fixture = new Fixture(); + when(fixture.scheduler.runAsync(any())) + .thenThrow(new IllegalStateException("submission failed")); + + 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 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 = + 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)); + + 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)); + } + + private void runInParcelCallback(Runnable callback) { + this.callbackContext.runParcelOperationCallback(this.parcel.uuid(), callback); + } + } +} 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/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 000000000..d9e3244f7 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerConsistencyTest.java @@ -0,0 +1,306 @@ +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 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(); + 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 new file mode 100644 index 000000000..9adfce93b --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManagerReservationTest.java @@ -0,0 +1,82 @@ +package com.eternalcode.parcellockers.itemstorage; + +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; +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 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); + 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(); + + 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()); + verify(repository, times(2)).save(org.mockito.ArgumentMatchers.any()); + } +} 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 000000000..ceeae766f --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/locker/LockerManagerTest.java @@ -0,0 +1,109 @@ +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; +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.Optional; +import java.util.concurrent.CompletableFuture; +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()); + } + + @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/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/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 new file mode 100644 index 000000000..5033bea3f --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -0,0 +1,481 @@ +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.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; + +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.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class AdminParcelServiceTest { + + @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 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); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.changeStatus(delivered, ParcelStatus.SENT).join()); + + 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(delivered); + 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.create(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.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)); + }); + 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).updateIfStatusWithinParcelOperation( + any(), eq(ParcelStatus.SENT)); + } + + @Test + void capacityMatchesContentGuiUsableSlots() { + assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); + assertEquals(18, AdminParcelService.capacity(ParcelSize.MEDIUM)); + assertEquals(27, AdminParcelService.capacity(ParcelSize.LARGE)); + } + + @Test + void enablingPriorityShortensDeliveryByDelta() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(5)); // normal delivery scheduled in 5 min + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); + + // delta = priority - normal = -4 min; oldTs - 4 min = now + 1 min + assertEquals(now.plus(Duration.ofMinutes(1)), shifted); + } + + @Test + void disablingPriorityExtendsDeliveryByDelta() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(1)); + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, false, normal, priority, now); + + // delta = normal - priority = +4 min; oldTs + 4 min = now + 5 min + assertEquals(now.plus(Duration.ofMinutes(5)), shifted); + } + + @Test + void overdueShiftIsClampedToNow() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofSeconds(30)); // 30s left + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + // enabling priority: delta = -4 min, oldTs - 4 min is in the past -> clamp to now + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); + + assertEquals(now, shifted); + } + + @Test + void unchangedPriorityKeepsTimestamp() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Instant oldTs = now.plus(Duration.ofMinutes(3)); + Duration normal = Duration.ofMinutes(5); + Duration priority = Duration.ofMinutes(1); + + Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, true, normal, priority, now); + + assertEquals(oldTs, shifted); + } + + 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, + 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); + } + + 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/ParcelDispatchServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java new file mode 100644 index 000000000..ea11db11e --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -0,0 +1,509 @@ +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.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.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; + +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.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; +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.ArrayList; +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.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; +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 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(); + 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()); + 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 + void successCompletesOnlyAfterDeliveryIsPersistedAndTaskIsScheduled() { + CompletableFuture deliveryCreated = new CompletableFuture<>(); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.reservation.delete()) + .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)); + verify(this.reservation).close(); + } + + @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()).sendWithinParcelOperation(any(), any(), any()); + 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()).sendWithinParcelOperation(any(), any(), any()); + } + + @Test + void cancelledPersistenceStepCompletesFalseWithoutContinuing() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + 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()); + + verify(this.itemStorageManager, never()).delete(any()); + } + + @Test + void parcelPersistenceFailureCompletesExceptionally() { + IllegalStateException persistenceFailure = + new IllegalStateException("persistence failed"); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.sendWithinParcelOperation(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.sendWithinParcelOperation(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.reservation.delete()) + .thenReturn(CompletableFuture.failedFuture(deleteFailure)); + when(this.parcelService.rollbackSendWithinParcelOperation(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()); + assertSame(deleteFailure, exception.getCause().getCause()); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); + verify(this.deliveryManager, never()).create(any(), any()); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + } + + @Test + void synchronousStorageDeleteFailureStillRollsBack() { + IllegalStateException deleteFailure = new IllegalStateException("delete failed"); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.reservation.delete()).thenThrow(deleteFailure); + when(this.parcelService.rollbackSendWithinParcelOperation(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).rollbackSendWithinParcelOperation(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())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.reservation.delete()) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenThrow(createFailure); + when(this.reservation.restore(this.items)) + .thenReturn(restored); + 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()).rollbackSendWithinParcelOperation(this.sender, this.parcel); + + restored.complete(mock(ItemStorage.class)); + + assertFalse(result.isDone()); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); + + rolledBack.complete(null); + 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 asynchronousDeliveryCreateFailureRollsBackAndCompletesExceptionally() { + IllegalStateException createFailure = new IllegalStateException("create failed"); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.sendWithinParcelOperation(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.reservation.delete()) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.failedFuture(createFailure)); + when(this.reservation.restore(this.items)) + .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); + when(this.parcelService.rollbackSendWithinParcelOperation(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()); + assertSame(createFailure, exception.getCause().getCause()); + verify(this.reservation).restore(this.items); + 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()); + } + + @Test + void schedulingFailureDeletesDeliveryThenRestoresStorageThenRollsBackExceptionally() { + IllegalStateException schedulingFailure = new IllegalStateException("schedule failed"); + 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(schedulingFailure); + when(this.deliveryManager.delete(this.parcel.uuid())).thenReturn(deliveryDeleted); + when(this.reservation.restore(this.items)).thenReturn(restored); + 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.deliveryManager).delete(this.parcel.uuid()); + verify(this.reservation, never()).restore(this.items); + + deliveryDeleted.complete(true); + verify(this.reservation).restore(this.items); + verify(this.parcelService, never()).rollbackSendWithinParcelOperation(this.sender, this.parcel); + + restored.complete(mock(ItemStorage.class)); + verify(this.parcelService).rollbackSendWithinParcelOperation(this.sender, this.parcel); + assertFalse(result.isDone()); + + rolledBack.complete(null); + CompletionException exception = + assertThrows(CompletionException.class, result::join); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(schedulingFailure, exception.getCause().getCause()); + } + + @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.reservation.restore(this.items)) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("restore failed"))); + when(this.parcelService.rollbackSendWithinParcelOperation(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + + 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).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 -> + record.getMessage().contains(this.parcel.uuid().toString()) + && record.getMessage().contains(this.parcel.sender().toString()) + && record.getThrown() == exception.getCause())); + } + + @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)); + when(this.reservation.restore(this.items)) + .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); + when(this.parcelService.rollbackSendWithinParcelOperation(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).restore(this.items); + verify(this.parcelService).rollbackSendWithinParcelOperation(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.rollbackSendWithinParcelOperation(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).rollbackSendWithinParcelOperation(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.reservation.restore(this.items)) + .thenReturn(CompletableFuture.completedFuture(mock(ItemStorage.class))); + when(this.parcelService.rollbackSendWithinParcelOperation(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.reservation, never()).restore(this.items); + 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.sendWithinParcelOperation(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + 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(); + } + + @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); + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(failure, operationException.getCause()); + verify(this.reservation).close(); + verify(this.lockerManager, never()).isLockerFull(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 + ); + } +} 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 000000000..94906e2b5 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -0,0 +1,963 @@ +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.mockingDetails; +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; + +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; +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 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.Executor; +import java.util.concurrent.TimeUnit; +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; +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.InOrder; +import org.mockito.MockedStatic; + +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(); + 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)); + AtomicReference> callbackDelete = new AtomicReference<>(); + + CompletableFuture send = + fixture.service.send(fixture.sender, fixture.parcel, fixture.items); + send.whenComplete((ignored, throwable) -> + callbackDelete.set(fixture.service.delete(fixture.parcel.uuid()))); + + contentSaved.complete(null); + + assertTrue(send.join()); + 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 + 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, timeout(1_000)).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(); + 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); + 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(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( + 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.saveIfAbsent(parcel)).thenReturn(CompletableFuture.completedFuture(true)); + 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()); + } + + @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(), + UUID.randomUUID(), + "name", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + 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(); + } + } + + 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() { + 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 = 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); + } + } +} 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 new file mode 100644 index 000000000..393cef185 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java @@ -0,0 +1,121 @@ +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(), + ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); + } + + @Test + void abortsWhenParcelMissing() { + assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.empty(), Optional.empty(), Instant.now())); + } + + @Test + void abortsWhenAlreadyDelivered() { + Parcel delivered = parcel(ParcelStatus.DELIVERED); + assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.of(delivered), Optional.empty(), Instant.now())); + } + + @Test + void reschedulesWhenDeliveryMovedToFuture() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + Delivery future = new Delivery(sent.uuid(), now.plus(Duration.ofMinutes(2))); + assertEquals(Decision.RESCHEDULE, ParcelSendTask.decide(Optional.of(sent), Optional.of(future), now)); + } + + @Test + void deliversWhenDueAndStillSent() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + Delivery due = new Delivery(sent.uuid(), now.minus(Duration.ofSeconds(1))); + assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.of(due), now)); + } + + @Test + void deliversWhenSentWithNoDeliveryRow() { + Instant now = Instant.parse("2026-06-21T12:00:00Z"); + Parcel sent = parcel(ParcelStatus.SENT); + assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.empty(), now)); + } + + @Test + void abortsWhenAlreadyCollected() { + // A COLLECTED parcel sits in the receiver's return window; a stale delivery row + // must not re-deliver it, or the items could be collected twice. + Parcel collected = parcel(ParcelStatus.COLLECTED); + Delivery due = new Delivery(collected.uuid(), Instant.parse("2026-06-21T11:00:00Z")); + assertEquals(Decision.ABORT, ParcelSendTask.decide( + Optional.of(collected), Optional.of(due), Instant.parse("2026-06-21T12:00:00Z"))); + } +} 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 98% rename from src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java index 1ba75c219..7261e1ab8 100644 --- a/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); 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/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 000000000..d1376d839 --- /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); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 79294bb67..5f804553f 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" } diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java deleted file mode 100644 index 358d51a26..000000000 --- a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.eternalcode.parcellockers.itemstorage; - -import com.eternalcode.parcellockers.itemstorage.event.ItemStorageUpdateEvent; -import com.eternalcode.parcellockers.itemstorage.repository.ItemStorageRepository; -import com.eternalcode.parcellockers.notification.NoticeService; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import org.bukkit.Server; -import org.bukkit.command.CommandSender; -import org.bukkit.inventory.ItemStack; - -public class ItemStorageManager { - - private final Cache cache; - - private final ItemStorageRepository itemStorageRepository; - private final Server server; - - public ItemStorageManager(ItemStorageRepository itemStorageRepository, Server server) { - this.itemStorageRepository = itemStorageRepository; - this.server = server; - - this.cache = Caffeine.newBuilder() - .expireAfterWrite(6, TimeUnit.HOURS) - .maximumSize(10_000) - .build(); - - this.cacheAll(); - } - - 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; - }); - } - - 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); - } - - public CompletableFuture create(UUID owner, List items) { - 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 (event.isCancelled()) { - return CompletableFuture.failedFuture(new IllegalStateException("ItemStorage update was cancelled by event")); - } - - this.cache.put(owner, newItemStorage); - // Return the save future so callers can react to a persistence failure instead of losing items. - // If the save fails, undo the optimistic cache update so the cache never holds an unpersisted - // storage (which, combined with re-giving the items to the player, would duplicate them). - return this.itemStorageRepository.save(newItemStorage) - .whenComplete((ignored, throwable) -> { - if (throwable != null) { - if (oldItemStorage != null) { - this.cache.put(owner, oldItemStorage); - } else { - this.cache.invalidate(owner); - } - } - }) - .thenApply(ignored -> newItemStorage); - } - - private void cacheAll() { - this.itemStorageRepository.fetchAll() - .thenAccept(all -> all.ifPresent(list -> list.forEach(itemStorage -> this.cache.put( - itemStorage.owner(), - itemStorage)))); - } - - public CompletableFuture delete(UUID owner) { - return this.itemStorageRepository.delete(owner).thenApply(i -> { - this.cache.invalidate(owner); - return i > 0; - }); - } - - public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { - return this.itemStorageRepository.deleteAll().thenAccept(deleted -> { - noticeService.create() - .viewer(sender) - .notice(messages -> messages.admin.deletedItemStorages) - .placeholder("{COUNT}", deleted.toString()) - .send(); - - this.cache.invalidateAll(); - }); - } -} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java deleted file mode 100644 index 01547f50b..000000000 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.eternalcode.parcellockers.parcel.service; - -import com.eternalcode.commons.scheduler.Scheduler; -import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; -import com.eternalcode.parcellockers.content.ParcelContentManager; -import com.eternalcode.parcellockers.delivery.DeliveryManager; -import com.eternalcode.parcellockers.locker.LockerManager; -import com.eternalcode.parcellockers.parcel.Parcel; -import com.eternalcode.parcellockers.parcel.ParcelSize; -import com.eternalcode.parcellockers.parcel.ParcelStatus; -import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; -import java.time.Duration; -import java.time.Instant; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; - -public class AdminParcelService { - - private final ParcelService 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, - DeliveryManager deliveryManager, LockerManager lockerManager, PluginConfig config, Scheduler scheduler) { - this.parcelService = parcelService; - this.parcelContentManager = parcelContentManager; - this.deliveryManager = deliveryManager; - this.lockerManager = lockerManager; - this.config = config; - this.scheduler = scheduler; - } - - public static int capacity(ParcelSize size) { - return switch (size) { - case SMALL -> 9; - case MEDIUM -> 18; - case LARGE -> 27; - }; - } - - /** Pure delta-shift helper, clamped to never be before {@code now}. */ - public static Instant shiftedDeliveryTimestamp(Instant oldTimestamp, boolean oldPriority, boolean newPriority, - Duration normalDuration, Duration priorityDuration, Instant now) { - Duration oldDuration = oldPriority ? priorityDuration : normalDuration; - Duration newDuration = newPriority ? priorityDuration : normalDuration; - Instant shifted = oldTimestamp.plus(newDuration).minus(oldDuration); - 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)); - } - - public CompletableFuture changeDescription(Parcel parcel, String description) { - return this.persist(withDescription(parcel, description)); - } - - /** - * A COLLECTED parcel's content row is the collection snapshot, not a live shipment; flipping - * its status (e.g. back to SENT) would arm a delivery and hand the receiver the same items - * a second time. Refuse the change outright — a return is the only supported transition out - * of COLLECTED. - */ - public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { - if (parcel.status() == ParcelStatus.COLLECTED) { - return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); - } - Parcel updated = withStatus(parcel, status); - // Conditional on the snapshot's status: a concurrent collect between the GUI opening and - // this edit landing must not resurrect a COLLECTED parcel back to SENT/DELIVERED. - return this.parcelService.updateIfStatus(updated, parcel.status()).thenCompose(applied -> { - if (!Boolean.TRUE.equals(applied)) { - return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); - } - if (status == parcel.status()) { - return CompletableFuture.completedFuture(EditResult.ok()); - } - return status == ParcelStatus.SENT ? this.armDelivery(updated) : this.disarmDelivery(updated); - }); - } - - /** - * 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(); - }); - }); - } - - /** 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 void scheduleSend(Parcel parcel, Duration delay) { - this.scheduler.runLaterAsync( - new ParcelSendTask(parcel, this.parcelService, this.deliveryManager, this.scheduler), - delay.isNegative() ? Duration.ZERO : delay); - } - - public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { - return this.persist(withReceiver(parcel, receiver)); - } - - public CompletableFuture changeSize(Parcel parcel, ParcelSize newSize) { - return this.parcelContentManager.get(parcel.uuid()).thenCompose(optional -> { - int itemCount = optional.map(content -> content.items().size()).orElse(0); - if (itemCount > capacity(newSize)) { - return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.SIZE_TOO_SMALL)); - } - return this.persist(withSize(parcel, newSize)); - }); - } - - public CompletableFuture changeDestination(Parcel parcel, UUID destinationLocker) { - return this.lockerManager.isLockerFull(destinationLocker).thenCompose(full -> { - if (Boolean.TRUE.equals(full)) { - return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.DESTINATION_FULL)); - } - return this.persist(withDestination(parcel, destinationLocker)); - }); - } - - public CompletableFuture changePriority(Parcel parcel, boolean newPriority) { - Parcel updated = withPriority(parcel, newPriority); - return this.parcelService.update(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 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) - .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(); - }); - }); - }); - } - - 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()); - } - - private static Parcel withDescription(Parcel p, String description) { - return new Parcel(p.uuid(), p.sender(), p.name(), description, p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); - } - - private static Parcel withPriority(Parcel p, boolean priority) { - return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), priority, p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), p.status()); - } - - private static Parcel withSize(Parcel p, ParcelSize size) { - return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), size, p.entryLocker(), p.destinationLocker(), p.status()); - } - - private static Parcel withStatus(Parcel p, ParcelStatus status) { - return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), p.destinationLocker(), status); - } - - private static Parcel withReceiver(Parcel p, UUID receiver) { - return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), receiver, p.size(), p.entryLocker(), p.destinationLocker(), p.status()); - } - - private static Parcel withDestination(Parcel p, UUID destinationLocker) { - return new Parcel(p.uuid(), p.sender(), p.name(), p.description(), p.priority(), p.receiver(), p.size(), p.entryLocker(), destinationLocker, p.status()); - } -} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java deleted file mode 100644 index 9cf928a6d..000000000 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.eternalcode.parcellockers.parcel.service; - -import com.eternalcode.commons.scheduler.Scheduler; -import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; -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.task.ParcelSendTask; -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.ConcurrentHashMap; -import java.util.logging.Logger; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; - -public class ParcelDispatchService { - - private static final Logger LOGGER = Logger.getLogger(ParcelDispatchService.class.getName()); - - private final LockerManager lockerManager; - private final ParcelService parcelService; - private final DeliveryManager deliveryManager; - private final ItemStorageManager itemStorageManager; - private final Scheduler scheduler; - private final PluginConfig config; - private final NoticeService noticeService; - - // 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<>(); - - public ParcelDispatchService( - LockerManager lockerManager, - ParcelService parcelService, - DeliveryManager deliveryManager, - ItemStorageManager itemStorageManager, - Scheduler scheduler, - PluginConfig config, - NoticeService noticeService - ) { - this.lockerManager = lockerManager; - this.parcelService = parcelService; - this.deliveryManager = deliveryManager; - this.itemStorageManager = itemStorageManager; - this.scheduler = scheduler; - this.config = config; - this.noticeService = noticeService; - } - - public void dispatch(Player sender, Parcel parcel, List items) { - 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)); - }); - - // Drop the chain entry once it drains so the map does not grow unbounded. - chained.whenComplete((result, throwable) -> this.lockerChains.remove(lockerId, chained)); - } - - 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); - } - - Duration delay = parcel.priority() - ? this.config.settings.priorityParcelSendDuration - : this.config.settings.parcelSendDuration; - - return this.parcelService.send(sender, parcel, items) - .thenCompose(success -> { - if (!Boolean.TRUE.equals(success)) { - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return CompletableFuture.completedFuture(null); - } - - return this.itemStorageManager.delete(sender.getUniqueId()) - // 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 -> { - 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. - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return this.parcelService.rollbackSend(sender, parcel); - } - - this.deliveryManager.create(parcel.uuid(), Instant.now().plus(delay)); - - 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 CompletableFuture.completedFuture(null); - }); - }); - }) - .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; - }); - } -} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java deleted file mode 100644 index fbfe57780..000000000 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ /dev/null @@ -1,419 +0,0 @@ -package com.eternalcode.parcellockers.parcel.service; - -import static com.eternalcode.parcellockers.util.InventoryUtil.canHold; - -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; -import com.eternalcode.parcellockers.parcel.ParcelSize; -import com.eternalcode.parcellockers.parcel.ParcelStatus; -import com.eternalcode.parcellockers.parcel.event.ParcelCollectEvent; -import com.eternalcode.parcellockers.parcel.event.ParcelSendEvent; -import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; -import com.eternalcode.parcellockers.returns.CollectedParcel; -import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; -import com.eternalcode.parcellockers.shared.Page; -import com.eternalcode.parcellockers.shared.PageResult; -import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.google.common.base.Preconditions; -import java.time.Instant; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import net.milkbowl.vault.economy.Economy; -import org.bukkit.Server; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; - -public class ParcelServiceImpl implements ParcelService { - - private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; - private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; - private static final String PLACEHOLDER_COUNT = "{COUNT}"; - - private static final long CACHE_EXPIRE_HOURS = 3; - private static final long CACHE_MAX_SIZE = 10_000; - - private final NoticeService noticeService; - private final ParcelRepository parcelRepository; - private final ParcelContentRepository parcelContentRepository; - private final CollectedParcelRepository collectedParcelRepository; - private final Scheduler scheduler; - private final PluginConfig config; - private final Economy economy; - private final Server server; - - private final Cache parcelsByUuid; - - public ParcelServiceImpl( - NoticeService noticeService, - ParcelRepository parcelRepository, - ParcelContentRepository parcelContentRepository, - CollectedParcelRepository collectedParcelRepository, - Scheduler scheduler, - PluginConfig config, - Economy economy, - Server server - ) { - this.noticeService = noticeService; - this.parcelRepository = parcelRepository; - this.parcelContentRepository = parcelContentRepository; - this.collectedParcelRepository = collectedParcelRepository; - this.scheduler = scheduler; - this.config = config; - this.economy = economy; - this.server = server; - - this.parcelsByUuid = Caffeine.newBuilder() - .expireAfterAccess(CACHE_EXPIRE_HOURS, TimeUnit.HOURS) - .maximumSize(CACHE_MAX_SIZE) - .build(); - } - - @Override - public CompletableFuture send(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); - - if (event.isCancelled()) { - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - return CompletableFuture.completedFuture(false); - } - - List itemsCopy = items.stream() - .map(ItemStack::clone) - .toList(); - - double chargedFee = 0; - 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.noticeService.create() - .notice(messages -> messages.parcel.insufficientFunds) - .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; - return 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)))) - ) - .exceptionally(throwable -> { - // Persistence failed after the fee was withdrawn - refund it so the player is not charged for a parcel that was never created. - this.refundFee(sender, refundableFee); - this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); - throw new ParcelOperationException("Failed to save parcel", throwable); - }); - } - - @Override - public CompletableFuture rollbackSend(Player sender, Parcel parcel) { - Objects.requireNonNull(sender, "Sender cannot be null"); - Objects.requireNonNull(parcel, "Parcel cannot be null"); - - if (!sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { - this.refundFee(sender, this.feeFor(parcel.size())); - } - this.parcelsByUuid.invalidate(parcel.uuid()); - - return this.parcelRepository.delete(parcel.uuid()) - .thenCompose(deleted -> this.parcelContentRepository.delete(parcel.uuid())) - .thenApply(contentDeleted -> null); - } - - private double feeFor(ParcelSize size) { - return switch (size) { - case SMALL -> this.config.settings.smallParcelFee; - case MEDIUM -> this.config.settings.mediumParcelFee; - case LARGE -> this.config.settings.largeParcelFee; - }; - } - - private void refundFee(Player sender, double fee) { - if (fee > 0) { - this.economy.depositPlayer(sender, fee); - } - } - - @Override - public CompletableFuture update(Parcel updated) { - Objects.requireNonNull(updated, "Updated parcel cannot be null"); - this.parcelsByUuid.put(updated.uuid(), updated); - return this.parcelRepository.update(updated); - } - - @Override - public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { - Objects.requireNonNull(updated, "Updated parcel cannot be null"); - Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); - return this.parcelRepository.updateIfStatus(updated, expectedStatus).thenApply(applied -> { - if (applied) { - this.parcelsByUuid.put(updated.uuid(), updated); - } else { - this.parcelsByUuid.invalidate(updated.uuid()); - } - return applied; - }); - } - - @Override - public CompletableFuture delete(CommandSender sender, Parcel parcel) { - Objects.requireNonNull(sender, "Sender cannot be null"); - Objects.requireNonNull(parcel, "Parcel cannot be null"); - - return this.parcelRepository.delete(parcel) - .thenAccept(unused -> { - this.noticeService.create() - .notice(messages -> messages.parcel.deleted) - .viewer(sender) - .send(); - this.parcelsByUuid.invalidate(parcel.uuid()); - }) - .exceptionally(throwable -> { - this.noticeService.create() - .notice(messages -> messages.parcel.cannotDelete) - .viewer(sender) - .send(); - return null; - }); - } - - @Override - 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 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; - } - - 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); - }) - .exceptionally(throwable -> { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); - result.complete(null); - return null; - }); - }); - - 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(), - parcel.destinationLocker(), status); - } - - @Override - public CompletableFuture> get(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); - - Parcel cached = this.parcelsByUuid.getIfPresent(uuid); - if (cached != null) { - return CompletableFuture.completedFuture(Optional.of(cached)); - } - return this.parcelRepository.findById(uuid).thenApply(optional -> { - optional.ifPresent(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); - return optional; - }); - } - - @Override - public void invalidate(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); - this.parcelsByUuid.invalidate(uuid); - } - - @Override - public CompletableFuture> getBySender(UUID sender, Page page) { - Objects.requireNonNull(sender, "Sender UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); - - return this.parcelRepository.findBySender(sender, page) - .thenApply(result -> { - result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); - return result; - }); - } - - @Override - public CompletableFuture> getByReceiver(UUID receiver, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); - - return this.parcelRepository.findByReceiver(receiver, page) - .thenApply(result -> { - result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); - return result; - }); - } - - @Override - public CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); - - return this.parcelRepository.findCollectible(receiver, destinationLocker, page) - .thenApply(result -> { - result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); - return result; - }); - } - - @Override - public CompletableFuture> getReturnable(UUID receiver, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); - - return this.parcelRepository.findReturnable(receiver, page) - .thenApply(result -> { - result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); - return result; - }); - } - - @Override - public CompletableFuture> getAll(Page page) { - Objects.requireNonNull(page, "Page cannot be null"); - return this.parcelRepository.findPage(page) - .thenApply(result -> { - result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); - return result; - }); - } - - @Override - public CompletableFuture delete(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); - return this.parcelRepository.delete(uuid).thenCompose(deleted -> { - if (!deleted) { - return CompletableFuture.completedFuture(false); - } - this.parcelsByUuid.invalidate(uuid); - // The parcel row is gone, so reclaim its content row to avoid an orphaned leak. - // A failed content delete only leaves an orphaned row (logged); it never affects - // the already-deleted parcel, so the operation still reports success. - return this.parcelContentRepository.delete(uuid) - .exceptionally(throwable -> { - this.server.getLogger().warning("Failed to delete content for deleted parcel " - + uuid + ": " + throwable.getMessage()); - return false; - }) - .thenApply(contentDeleted -> true); - }); - } - - @Override - public CompletableFuture delete(Parcel parcel) { - Objects.requireNonNull(parcel, "Parcel cannot be null"); - return this.delete(parcel.uuid()); - } - - @Override - public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { - Objects.requireNonNull(sender, "Sender cannot be null"); - Objects.requireNonNull(noticeService, "NoticeService cannot be null"); - - return this.parcelRepository.deleteAll().thenCompose(deleted -> { - noticeService.create() - .notice(messages -> messages.admin.deletedParcels) - .viewer(sender) - .placeholder(PLACEHOLDER_COUNT, deleted.toString()) - .send(); - - this.parcelsByUuid.invalidateAll(); - - // Reclaim every content row alongside the parcels so a bulk delete leaves nothing orphaned. - return this.parcelContentRepository.deleteAll() - .exceptionally(throwable -> { - this.server.getLogger().warning("Failed to delete parcel contents during bulk delete: " - + throwable.getMessage()); - return 0; - }) - .thenAccept(contentDeleted -> {}); - }); - } -} diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java deleted file mode 100644 index aa33f5a2f..000000000 --- a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.eternalcode.parcellockers.parcel.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.eternalcode.parcellockers.parcel.Parcel; -import com.eternalcode.parcellockers.parcel.ParcelSize; -import com.eternalcode.parcellockers.parcel.ParcelStatus; -import java.time.Duration; -import java.time.Instant; -import java.util.UUID; -import org.junit.jupiter.api.Test; - -class AdminParcelServiceTest { - - private static Parcel collectedParcel() { - return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "name", "description", false, - UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED); - } - - @Test - void changeStatusRefusesCollectedParcelWithoutTouchingAnyCollaborator() { - // No collaborator is a usable mock/instance here on purpose: the COLLECTED guard must - // short-circuit before dereferencing any of them, so a regression that removes the guard - // and reaches a collaborator call would fail this test with an NPE instead of passing. - AdminParcelService service = new AdminParcelService(null, null, null, null, null, null); - - Parcel collected = collectedParcel(); - EditResult result = service.changeStatus(collected, ParcelStatus.SENT).join(); - - assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); - } - - @Test - void capacityMatchesContentGuiUsableSlots() { - assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); - assertEquals(18, AdminParcelService.capacity(ParcelSize.MEDIUM)); - assertEquals(27, AdminParcelService.capacity(ParcelSize.LARGE)); - } - - @Test - void enablingPriorityShortensDeliveryByDelta() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Instant oldTs = now.plus(Duration.ofMinutes(5)); // normal delivery scheduled in 5 min - Duration normal = Duration.ofMinutes(5); - Duration priority = Duration.ofMinutes(1); - - Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); - - // delta = priority - normal = -4 min; oldTs - 4 min = now + 1 min - assertEquals(now.plus(Duration.ofMinutes(1)), shifted); - } - - @Test - void disablingPriorityExtendsDeliveryByDelta() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Instant oldTs = now.plus(Duration.ofMinutes(1)); - Duration normal = Duration.ofMinutes(5); - Duration priority = Duration.ofMinutes(1); - - Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, false, normal, priority, now); - - // delta = normal - priority = +4 min; oldTs + 4 min = now + 5 min - assertEquals(now.plus(Duration.ofMinutes(5)), shifted); - } - - @Test - void overdueShiftIsClampedToNow() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Instant oldTs = now.plus(Duration.ofSeconds(30)); // 30s left - Duration normal = Duration.ofMinutes(5); - Duration priority = Duration.ofMinutes(1); - - // enabling priority: delta = -4 min, oldTs - 4 min is in the past -> clamp to now - Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, false, true, normal, priority, now); - - assertEquals(now, shifted); - } - - @Test - void unchangedPriorityKeepsTimestamp() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Instant oldTs = now.plus(Duration.ofMinutes(3)); - Duration normal = Duration.ofMinutes(5); - Duration priority = Duration.ofMinutes(1); - - Instant shifted = AdminParcelService.shiftedDeliveryTimestamp(oldTs, true, true, normal, priority, now); - - assertEquals(oldTs, shifted); - } -} diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java deleted file mode 100644 index 29014a276..000000000 --- a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.eternalcode.parcellockers.parcel.task; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.eternalcode.parcellockers.delivery.Delivery; -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 java.time.Duration; -import java.time.Instant; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.Test; - -class ParcelSendTaskTest { - - private static Parcel parcel(ParcelStatus status) { - UUID id = UUID.randomUUID(); - return new Parcel(id, UUID.randomUUID(), "n", "d", false, UUID.randomUUID(), - ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); - } - - @Test - void abortsWhenParcelMissing() { - assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.empty(), Optional.empty(), Instant.now())); - } - - @Test - void abortsWhenAlreadyDelivered() { - Parcel delivered = parcel(ParcelStatus.DELIVERED); - assertEquals(Decision.ABORT, ParcelSendTask.decide(Optional.of(delivered), Optional.empty(), Instant.now())); - } - - @Test - void reschedulesWhenDeliveryMovedToFuture() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Parcel sent = parcel(ParcelStatus.SENT); - Delivery future = new Delivery(sent.uuid(), now.plus(Duration.ofMinutes(2))); - assertEquals(Decision.RESCHEDULE, ParcelSendTask.decide(Optional.of(sent), Optional.of(future), now)); - } - - @Test - void deliversWhenDueAndStillSent() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Parcel sent = parcel(ParcelStatus.SENT); - Delivery due = new Delivery(sent.uuid(), now.minus(Duration.ofSeconds(1))); - assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.of(due), now)); - } - - @Test - void deliversWhenSentWithNoDeliveryRow() { - Instant now = Instant.parse("2026-06-21T12:00:00Z"); - Parcel sent = parcel(ParcelStatus.SENT); - assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.empty(), now)); - } - - @Test - void abortsWhenAlreadyCollected() { - // A COLLECTED parcel sits in the receiver's return window; a stale delivery row - // must not re-deliver it, or the items could be collected twice. - Parcel collected = parcel(ParcelStatus.COLLECTED); - Delivery due = new Delivery(collected.uuid(), Instant.parse("2026-06-21T11:00:00Z")); - assertEquals(Decision.ABORT, ParcelSendTask.decide( - Optional.of(collected), Optional.of(due), Instant.parse("2026-06-21T12:00:00Z"))); - } -}