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/AGENTS.md b/AGENTS.md index 05a9b7370..a0805ddb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,16 @@ # AGENTS.md -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. +This file provides guidance to AI agents when working with code in this repository. + +## Philosophy and Code Style + +- You can communicate all of your views and report submit objections to the user's way of thinking. +- Use 'this' wherever possible (this.field, this.method(), etc.). +- Use SOLID, KISS, Clean Code principles, DRY (Don't Repeat Yourself), no copy-paste coding, Defensive copying for safety +- Prefer meaningful variable names +- Use fail-fast approach, validate early, throw meaningful exceptions +- Create immutable objects where possible, use builder pattern for complex objects, no magic numbers/strings - prefer constants +- If asked by user for a simple task/edit - do not invoke unnecessary skills such as TDD or planning. ## Build & Run Commands @@ -15,25 +25,30 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t ./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelRepositoryIntegrationTest" # Start a local Paper server with the plugin loaded (downloads Paper + dependencies automatically) -./gradlew runServer +./gradlew runServer --console=plain ``` -Requires JDK 21+. The `runServer` task uses an Adoptium Java 25 toolchain and auto-downloads LuckPerms, VaultUnlocked, and EssentialsX. To train its AOT cache, comment out `-XX:AOTCache=server.aot`, uncomment `-XX:AOTCacheOutput=server.aot`, run the server with a representative workload, and stop it cleanly. Restore the original comments for subsequent runs. The cache is stored as `run/server.aot` and includes all classes loaded during training, including ParcelLockers. Uncomment the DiscordSRV line in `build.gradle.kts` to test that integration locally. +Requires JDK 21+. The `runServer` task uses a Java 25 toolchain and auto-downloads LuckPerms, VaultUnlocked, and EssentialsX. Uncomment the DiscordSRV line in `build.gradle.kts` to test that integration locally. ## Architecture Overview -ParcelLockers is a Paper plugin (Minecraft 1.21) that lets players transfer items between parcel locker blocks across the world, with optional Discord notifications. +ParcelLockers is a Paper plugin (Minecraft 1.21+) that lets players transfer items between parcel locker blocks across the world, with optional Discord notifications. ### Entry Point & Wiring -`ParcelLockers.java` (`onEnable`) is the manual DI root — all components are instantiated and wired there in order: config → database → repositories → managers/services → GUIs → commands → event controllers. There is no DI framework; dependencies are passed via constructors. +The project consists of two Gradle modules: + +- `parcellockers-api` — the public API other plugins' developers can hook into: `ParcelLockersApi` (obtained via `ParcelLockersProvider`), `LockerService`, `ParcelService`, the API models (`Locker`, `Parcel`, `Position`, `Page`/`PageResult`), cancellable events (`LockerCreateEvent`, `LockerDeleteEvent`, `ParcelSendEvent`, `ParcelDeliverEvent`, `ParcelCollectEvent`, `ParcelReturnEvent`) and exceptions (`ParcelLockersException`, `ValidationException`, `ParcelOperationException`). +- `parcellockers-plugin` — the plugin itself and the API implementation (e.g. `PublicParcelService` implements `ParcelService`). + +`ParcelLockers.java` (`onEnable`) is the manual DI root — all components are instantiated and wired there in order: config → `NoticeService` → Vault economy lookup (the plugin disables itself if none is found) → database → repositories → managers/services → GUIs → commands → event controllers → `ParcelLockersProvider.initialize` (reverted in `onDisable`). There is no DI framework; dependencies are passed via constructors. ### Domain Layers -Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, `discord`) follows a consistent layered structure: +Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `returns`, `user`, `discord`) follows a consistent layered structure: - **Model** — plain record/class (e.g. `Locker`, `Parcel`, `User`) -- **Repository** — interface + `*OrmLite` implementation backed by H2/PostgreSQL via ORMLite +- **Repository** — interface + `*OrmLite` implementation backed by ORMLite (any supported SQL database) - **Manager/Service** — business logic, coordinates repository calls; repositories return `CompletableFuture` for all async DB operations - **Controller** — Bukkit `Listener` handling in-game events (block place/break/interact, player join/quit) @@ -41,13 +56,14 @@ Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, ` | Component | Purpose | |---|---| -| `DatabaseManager` | Manages HikariCP connection pool; supports H2 (default, embedded) and PostgreSQL | +| `DatabaseManager` | Manages HikariCP connection pool; supports SQLite (default), H2, MySQL, MariaDB and PostgreSQL (`DatabaseType`) | | `ConfigService` + okaeri-configs | Loads `config.yml` and `messages.yml` via YAML; `PluginConfig` and `MessageConfig` are the config POJOs | | `NoticeService` + multification | Sends MiniMessage-formatted notices to players; all user-facing text goes through `MessageConfig` | | `ParcelDispatchService` | Orchestrates sending a parcel: validates, charges economy (Vault), schedules `ParcelSendTask` | | `ParcelSendTask` | Runs async after a configurable delay; marks parcel DELIVERED and fires the deliver notification event | | `GuiManager` + triumph-gui | Factory for all inventory GUIs; `LockerGui` and `MainGui` are the two root GUI entry points | | `DiscordProviderPicker` | Selects between Discord4J (standalone bot) and DiscordSRV (delegation) at startup based on detected plugins | +| `ParcelReturnService` | Handles returning collected parcels within a configurable window; `ParcelReturnValidator` checks the returned items, `ReturnWindowPurgeTask` purges expired entries | | `LockerPlaceController` | Uses Paper's Dialog API (unstable) to prompt for a locker description when a player places the locker item | ### Optional Integrations @@ -57,4 +73,4 @@ Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, ` ### Testing -Tests live in `src/test/java/`. Integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `IntegrationTestSpec` and use Testcontainers (MySQL) to test repository implementations against a real database. `ParcelPageTest` is a unit test with no container dependency. +Tests live in `parcellockers-plugin/src/test/java/` and `parcellockers-api/src/test/java/`. Repository integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `MySqlIntegrationTestSpec`, which shares a single Testcontainers MySQL container across test classes and resets the schema before each test; `IntegrationTestSpec` only provides the `await()` helper for `CompletableFuture`s. `ParcelPageTest` is a unit test with no container dependency. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 071edc2d8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,71 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build & Run Commands - -```bash -# Build the plugin JAR (output: build/libs/ParcelLockers v.jar) -./gradlew shadowJar - -# Run tests -./gradlew test - -# Run a single test class -./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelRepositoryIntegrationTest" - -# Start a local Paper server with the plugin loaded (downloads Paper + dependencies automatically) -./gradlew runServer -``` - -Requires JDK 21+. The `runServer` task uses JetBrains JVM and auto-downloads LuckPerms, VaultUnlocked, and EssentialsX. Uncomment the DiscordSRV line in `build.gradle.kts` to test that integration locally. - -## Architecture Overview - -ParcelLockers is a Paper plugin (Minecraft 1.21) that lets players transfer items between parcel locker blocks across the world, with optional Discord notifications. - -### Entry Point & Wiring - -`ParcelLockers.java` (`onEnable`) is the manual DI root — all components are instantiated and wired there in order: config → database → repositories → managers/services → GUIs → commands → event controllers. There is no DI framework; dependencies are passed via constructors. - -### Domain Layers - -Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, `discord`) follows a consistent layered structure: - -- **Model** — plain record/class (e.g. `Locker`, `Parcel`, `User`) -- **Repository** — interface + `*OrmLite` implementation backed by H2/PostgreSQL via ORMLite -- **Manager/Service** — business logic, coordinates repository calls; repositories return `CompletableFuture` for all async DB operations -- **Controller** — Bukkit `Listener` handling in-game events (block place/break/interact, player join/quit) - -### Key Components - -| Component | Purpose | -|---|---| -| `DatabaseManager` | Manages HikariCP connection pool; supports H2 (default, embedded) and PostgreSQL | -| `ConfigService` + okaeri-configs | Loads `config.yml` and `messages.yml` via YAML; `PluginConfig` and `MessageConfig` are the config POJOs | -| `NoticeService` + multification | Sends MiniMessage-formatted notices to players; all user-facing text goes through `MessageConfig` | -| `ParcelDispatchService` | Orchestrates sending a parcel: validates, charges economy (Vault), schedules `ParcelSendTask` | -| `ParcelSendTask` | Runs async after a configurable delay; marks parcel DELIVERED and fires the deliver notification event | -| `GuiManager` + triumph-gui | Factory for all inventory GUIs; `LockerGui` and `MainGui` are the two root GUI entry points | -| `DiscordProviderPicker` | Selects between Discord4J (standalone bot) and DiscordSRV (delegation) at startup based on detected plugins | -| `LockerPlaceController` | Uses Paper's Dialog API (unstable) to prompt for a locker description when a player places the locker item | - -### Optional Integrations - -- **DiscordSRV** — when present, account linking and DM notifications are delegated to it; otherwise, the plugin manages its own Discord bot via Discord4J -- **Nexo** — when present, custom item blocks can be used as locker blocks (`NexoIntegration.placeBlock`); guarded by `isPluginEnabled("Nexo")` checks -- **Vault** — required; used to charge players an economy fee when sending parcels - -### Testing - -Tests live in `src/test/java/`. Integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `IntegrationTestSpec` and use Testcontainers (MySQL) to test repository implementations against a real database. `ParcelPageTest` is a unit test with no container dependency. - -## graphify - -This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. - -Rules: -- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. -- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. -- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. -- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a13db8270..39c3debb8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,211 +1,6 @@ -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.1.0" - 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 - } - } -} - -tasks.withType { - options.encoding = "UTF-8" - options.isIncremental = true - options.compilerArgs.add("-parameters") - options.release = 21 -} - -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") - } - - test { - 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" - } + id("de.eldoria.plugin-yml.paper") version "0.9.0" apply false + id("xyz.jpenilla.run-paper") version "3.1.0" apply false + id("com.gradleup.shadow") version "9.6.1" apply false + id("com.modrinth.minotaur") version "2.+" apply false } diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index a0d12aedc..5b550d122 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/buildSrc/src/main/kotlin/parcellockers-java.gradle.kts b/buildSrc/src/main/kotlin/parcellockers-java.gradle.kts new file mode 100644 index 000000000..fb753947a --- /dev/null +++ b/buildSrc/src/main/kotlin/parcellockers-java.gradle.kts @@ -0,0 +1,31 @@ +plugins { + java +} + +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 +} + +java { + 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() +} 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..bb1ac9ea2 --- /dev/null +++ b/parcellockers-api/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("parcellockers-java") + `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 { + val snapshot = version.toString().endsWith("-SNAPSHOT") + name = if (snapshot) "eternalcodeSnapshots" else "eternalcodeReleases" + url = uri(if (snapshot) "https://repo.eternalcode.pl/snapshots" else "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 80% 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..649a8acae 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,10 +4,11 @@ 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 +/** + * 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 + */ public class LockerDeleteEvent extends CancellableEvent { private static final HandlerList HANDLER_LIST = new HandlerList(); @@ -33,7 +34,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/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java new file mode 100644 index 000000000..17308675c --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java @@ -0,0 +1,30 @@ +package com.eternalcode.parcellockers.parcel; + +import java.util.Objects; +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +public record Parcel( + UUID uuid, + UUID sender, + String name, + @Nullable String description, + boolean priority, + UUID receiver, + ParcelSize size, + UUID entryLocker, + UUID destinationLocker, + ParcelStatus status +) { + + public Parcel { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(sender, "sender"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(receiver, "receiver"); + Objects.requireNonNull(size, "size"); + Objects.requireNonNull(entryLocker, "entryLocker"); + Objects.requireNonNull(destinationLocker, "destinationLocker"); + Objects.requireNonNull(status, "status"); + } +} diff --git a/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java new file mode 100644 index 000000000..0a24a910b --- /dev/null +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java @@ -0,0 +1,20 @@ +package com.eternalcode.parcellockers.parcel; + +public enum ParcelSize { + SMALL(9), + MEDIUM(18), + LARGE(27); + + private final int capacity; + + ParcelSize(int capacity) { + this.capacity = capacity; + } + + /** + * @return the maximum number of item stacks a parcel of this size can hold + */ + public int capacity() { + return this.capacity; + } +} 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 99% rename from src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java rename to parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java index 6f8fb1e3f..6b140fcaf 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java +++ b/parcellockers-api/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java @@ -1,7 +1,6 @@ package com.eternalcode.parcellockers.parcel; public enum ParcelStatus { - SENT, DELIVERED, COLLECTED 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 52% 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..d7f0e82c2 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,28 @@ 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 +37,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/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..63629aef2 --- /dev/null +++ b/parcellockers-plugin/build.gradle.kts @@ -0,0 +1,177 @@ +import net.minecrell.pluginyml.paper.PaperPluginDescription +import xyz.jpenilla.runtask.task.AbstractRun + +plugins { + id("parcellockers-java") + 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") + } + + 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 90% rename from src/main/java/com/eternalcode/parcellockers/ParcelLockers.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 12ef2ba9b..72a2b25ae 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; @@ -83,13 +86,17 @@ import org.bukkit.event.HandlerList; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.java.JavaPlugin; +import org.jspecify.annotations.Nullable; -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 @Nullable LiteCommands liteCommands; + private @Nullable DatabaseManager databaseManager; + private @Nullable Economy economy; + private @Nullable DiscordClientManager discordClientManager; + private @Nullable ParcelService parcelService; + private @Nullable LockerService lockerService; + private boolean apiInitialized; @Override public void onEnable() { @@ -110,6 +117,7 @@ public void onEnable() { server.getPluginManager().disablePlugin(this); return; } + Economy economy = Objects.requireNonNull(this.economy); DatabaseManager databaseManager = new DatabaseManager(config, this.getLogger(), this.getDataFolder()); this.databaseManager = databaseManager; @@ -134,14 +142,13 @@ public void onEnable() { new ParcelReturnRepositoryOrmLite(databaseManager, scheduler); // service and managers - ParcelService parcelService = new ParcelServiceImpl( + PluginParcelService parcelService = new ParcelServiceImpl( noticeService, parcelRepository, parcelContentRepository, - collectedParcelRepository, scheduler, config, - this.economy, + economy, server ); @@ -149,6 +156,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); @@ -160,8 +168,10 @@ public void onEnable() { itemStorageManager, scheduler, config, - noticeService + noticeService, + this.getLogger() ); + this.parcelService = new PublicParcelService(parcelService, parcelDispatchService, scheduler); ParcelReturnValidator returnValidator = new ParcelReturnValidator(new ReturnItemEquivalence(config.settings.returnChecks)); ReturnMismatchFormatter returnMismatchFormatter = new ReturnMismatchFormatter(messageConfig.parcel); @@ -177,7 +187,7 @@ public void onEnable() { scheduler, config, noticeService, - this.economy, + economy, server ); @@ -274,10 +284,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 +314,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 93% rename from src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java index 5364c949f..db61cd290 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/ParcelLockersLibraryLoader.java @@ -14,12 +14,11 @@ import org.eclipse.aether.artifact.DefaultArtifact; import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.repository.RemoteRepository; -import org.jetbrains.annotations.NotNull; public class ParcelLockersLibraryLoader implements PluginLoader { @Override - public void classloader(@NotNull PluginClasspathBuilder classpathBuilder) { + public void classloader(PluginClasspathBuilder classpathBuilder) { MavenLibraryResolver resolver = new MavenLibraryResolver(); PluginLibraries pluginLibraries = this.load(); pluginLibraries.asDependencies().forEach(resolver::addDependency); 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..934db83b7 --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/PublicParcelService.java @@ -0,0 +1,135 @@ +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.ValidationException; +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.Executor; +import java.util.function.Function; +import java.util.function.Supplier; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * The {@link ParcelService} handed out to other plugins. It validates caller input at the API + * boundary and runs each mutation on the thread its Bukkit event requires. + */ +final class PublicParcelService implements ParcelService { + + private final PluginParcelService delegate; + private final ParcelDispatchService dispatcher; + private final Executor mainThread; + private final Executor asyncThread; + + PublicParcelService(PluginParcelService delegate, ParcelDispatchService dispatcher, Scheduler scheduler) { + this.delegate = delegate; + this.dispatcher = dispatcher; + this.mainThread = scheduler::run; + this.asyncThread = scheduler::runAsync; + } + + @Override + public CompletableFuture send(Player sender, Parcel parcel, List items) { + if (!sender.getUniqueId().equals(parcel.sender())) { + return CompletableFuture.failedFuture(new ValidationException("Sender does not own the parcel")); + } + if (parcel.status() != ParcelStatus.SENT) { + return CompletableFuture.failedFuture(new ValidationException("Parcel must have SENT status")); + } + if (items.isEmpty() || items.stream().anyMatch(Objects::isNull)) { + return CompletableFuture.failedFuture(new ValidationException("Items cannot be empty or contain null")); + } + if (items.size() > parcel.size().capacity()) { + return CompletableFuture.failedFuture(new ValidationException( + "A " + parcel.size() + " parcel can hold at most " + parcel.size().capacity() + " items")); + } + + // Snapshot the items now, so later changes by the caller do not leak into the parcel. + List snapshot = items.stream() + .map(ItemStack::clone) + .toList(); + // ParcelSendEvent is asynchronous, so the whole dispatch runs off the main thread. + return submit(this.asyncThread, () -> this.dispatcher.dispatch(sender, parcel, snapshot)); + } + + @Override + public CompletableFuture collect(Player player, Parcel parcel) { + if (!player.getUniqueId().equals(parcel.receiver())) { + return CompletableFuture.failedFuture(new ValidationException("Player is not the parcel receiver")); + } + if (parcel.status() != ParcelStatus.DELIVERED) { + return CompletableFuture.failedFuture(new ValidationException("Parcel must have DELIVERED status")); + } + // ParcelCollectEvent is synchronous, so the collection starts on the main thread. + return submit(this.mainThread, () -> this.delegate.collect(player, parcel)); + } + + @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> 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 static CompletableFuture submit(Executor executor, Supplier> operation) { + return CompletableFuture.supplyAsync(operation, executor).thenCompose(Function.identity()); + } +} 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 98% 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 index a06d6d812..454b9c52c 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -102,6 +102,10 @@ public static class ParcelMessages extends OkaeriConfig { .chat("&4✘ &cThe parcel destination locker is not set!") .sound(SoundEventKeys.ENTITY_ENDERMAN_AMBIENT) .build(); + public Notice destinationNotFound = Notice.builder() + .chat("&4✘ &cThe destination locker no longer exists! Please select another locker.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); public Notice lockerFull = Notice.builder() .chat("&4✘ &cThe destination locker is full! Please select another locker.") .sound(SoundEventKeys.ENTITY_VILLAGER_NO) 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 80% 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 index 4b07dd10f..0fd4b0f0b 100644 --- a/src/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/database/persister/PositionPersister.java @@ -11,14 +11,22 @@ public class PositionPersister extends BaseDataType { private static final PositionPersister INSTANCE = new PositionPersister(); + // VARCHAR instead of TEXT: MySQL cannot put the UNIQUE index on a TEXT column without a key length. + private static final int COLUMN_WIDTH = 255; + private PositionPersister() { - super(SqlType.LONG_STRING, new Class[] { Position.class }); + super(SqlType.STRING, new Class[] { Position.class }); } public static PositionPersister getSingleton() { return INSTANCE; } + @Override + public int getDefaultWidth() { + return COLUMN_WIDTH; + } + @Override public Object javaToSqlArg(FieldType fieldType, Object javaObject) { Position pos = (Position) javaObject; 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 91% 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 index 66ad99a6b..44297cc2b 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -12,6 +12,7 @@ import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; import discord4j.common.util.Snowflake; + import java.util.UUID; import java.util.concurrent.CompletableFuture; import org.bukkit.OfflinePlayer; @@ -81,12 +82,11 @@ void linkOther(@Context CommandSender sender, @Arg OfflinePlayer player, @Arg Sn return this.discordLinkService.createLink(playerUuid, discordIdLong) .thenAccept(createResult -> { - switch (createResult) { - case SUCCESS -> { - this.noticeService.viewer(sender, messages -> messages.discord.adminLinkSuccess); - this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); - } - default -> this.noticeService.viewer(sender, messages -> messages.discord.linkFailed); + if (createResult == LinkResult.SUCCESS) { + this.noticeService.viewer(sender, messages -> messages.discord.adminLinkSuccess); + this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); + } else { + this.noticeService.viewer(sender, messages -> messages.discord.linkFailed); } }); }) 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 91% 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 index a952af70e..989ee2430 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/notification/Discord4JNotificationService.java @@ -3,6 +3,8 @@ import discord4j.common.util.Snowflake; import discord4j.core.GatewayDiscordClient; import java.util.logging.Logger; + +import discord4j.core.object.entity.User; import reactor.core.scheduler.Schedulers; public class Discord4JNotificationService implements DiscordNotificationService { @@ -18,7 +20,7 @@ public Discord4JNotificationService(GatewayDiscordClient client, Logger logger) @Override public void sendPrivateMessage(long discordId, String message) { this.client.getUserById(Snowflake.of(discordId)) - .flatMap(user -> user.getPrivateChannel()) + .flatMap(User::getPrivateChannel) .flatMap(channel -> channel.createMessage(message)) .subscribeOn(Schedulers.boundedElastic()) .doOnError(error -> this.logger.warning("Failed to send private message to Discord user " + discordId + ": " + error.getMessage())) 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 93% 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 index 3b1cfab8a..729a12262 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/discord/verification/DiscordVerificationService.java @@ -3,9 +3,11 @@ import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.DiscordSettings; import com.eternalcode.parcellockers.discord.DiscordLinkService; +import com.eternalcode.parcellockers.discord.LinkResult; import com.eternalcode.parcellockers.notification.NoticeService; import discord4j.core.object.entity.User; import io.papermc.paper.dialog.Dialog; + import java.util.UUID; import net.kyori.adventure.text.minimessage.MiniMessage; import org.bukkit.entity.Player; @@ -111,9 +113,10 @@ private void handleVerification(Player player, String enteredCode) { this.verificationCache.invalidate(playerUuid); this.discordLinkService.createLink(playerUuid, verificationData.discordId()) .thenAccept(result -> { - switch (result) { - case SUCCESS -> this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); - default -> this.noticeService.player(playerUuid, messages -> messages.discord.linkFailed); + if (result == LinkResult.SUCCESS) { + this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); + } else { + this.noticeService.player(playerUuid, messages -> messages.discord.linkFailed); } }); }, 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 99% 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 index 78795cbc3..608439ce3 100644 --- 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 @@ -15,7 +15,6 @@ import net.kyori.adventure.text.minimessage.MiniMessage; import org.bukkit.entity.Player; -@SuppressWarnings("UnstableApiUsage") public class AdminGui implements GuiView { private static final int PARCELS_SLOT = 20; 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 97% 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 index b1e105174..20fcbd761 100644 --- 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 @@ -4,6 +4,7 @@ import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.content.ParcelContent; import com.eternalcode.parcellockers.gui.GuiManager; import com.eternalcode.parcellockers.gui.GuiView; import com.eternalcode.parcellockers.gui.PaginatedGuiRefresher; @@ -121,7 +122,7 @@ private CompletableFuture> createParcelItemAsync( ) { CompletableFuture> loreFuture = PlaceholderUtil.replaceParcelPlaceholdersAsync(parcel, parcelItem.lore(), this.guiManager); CompletableFuture> contentFuture = this.guiManager.getParcelContent(parcel.uuid()) - .thenApply(optional -> optional.map(content -> content.items()).orElse(List.of())); + .thenApply(optional -> optional.map(ParcelContent::items).orElse(List.of())); return loreFuture.thenCombine(contentFuture, (processedLore, items) -> () -> { ConfigItem item = parcelItem.clone(); 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 98% 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 index d387d35d0..d51c0c8f3 100644 --- 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 @@ -151,7 +151,7 @@ private CompletableFuture> createParcelItemAsync( .orElse(this.guiSettings.returnWindowExpiredLine)); return CompletableFutures.combine(loreFuture, contentFuture, windowLineFuture, (processedLore, items, windowLine) -> { - Supplier supplier = () -> { + return (Supplier) () -> { ConfigItem item = rowItem.clone(); item.name(item.name().replace("{NAME}", parcel.name())); @@ -178,7 +178,6 @@ private CompletableFuture> createParcelItemAsync( parcel ).show(player)); }; - return supplier; }).toCompletableFuture(); } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/SendingGuiState.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/MainGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/ParcelListGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/gui/implementation/remote/SentParcelsGui.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorage.java diff --git a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java similarity index 99% rename from src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java index 358d51a26..d98952802 100644 --- a/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/itemstorage/ItemStorageManager.java @@ -89,7 +89,7 @@ private void cacheAll() { itemStorage.owner(), itemStorage)))); } - + public CompletableFuture delete(UUID owner) { return this.itemStorageRepository.delete(owner).thenApply(i -> { this.cache.invalidate(owner); 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/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/package-info.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/locker/package-info.java new file mode 100644 index 000000000..aa90c6449 --- /dev/null +++ b/parcellockers-plugin/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/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/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/package-info.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/package-info.java new file mode 100644 index 000000000..5666a0fbe --- /dev/null +++ b/parcellockers-plugin/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/command/ParcelCommand.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java similarity index 93% 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 index 47b32af6e..07a5873e1 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/command/ParcelCommand.java @@ -7,7 +7,6 @@ import dev.rollczi.litecommands.annotations.permission.Permission; import org.bukkit.entity.Player; -@SuppressWarnings({"unused", "ClassCanBeRecord"}) @Command(name = "parcel") @Permission("parcellockers.command.parcel") public class ParcelCommand { 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 87% 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..7c15cec69 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); /** @@ -53,10 +61,9 @@ public interface ParcelRepository { CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page); /** - * Atomically flips a DELIVERED parcel to COLLECTED. Returns false when the parcel is missing - * or not DELIVERED — the caller must treat that as "someone else already collected it". + * Atomically claims a delivered parcel and records the start of its return window. */ - CompletableFuture markCollected(UUID uuid); + CompletableFuture commitCollection(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 78% 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..33c182866 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,11 +5,15 @@ 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.UpdateBuilder; +import com.j256.ormlite.stmt.Where; +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; @@ -34,20 +38,23 @@ public ParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler schedu @Override public CompletableFuture save(Parcel parcel) { - Objects.requireNonNull(parcel, "Parcel cannot be null"); return this.insertIfAbsent(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } + @Override + public CompletableFuture saveIfAbsent(Parcel parcel) { + 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"); return this.upsert(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } @Override public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { - Objects.requireNonNull(updated, "Parcel cannot be null"); - Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); return this.action(ParcelTable.class, dao -> { UpdateBuilder builder = dao.updateBuilder(); builder.updateColumnValue(SENDER_COLUMN, updated.sender()); @@ -69,14 +76,12 @@ public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus ex @Override public CompletableFuture> findById(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); return this.selectSafe(ParcelTable.class, uuid) .thenApply(optional -> optional.map(ParcelTable::toParcel)); } @Override public CompletableFuture> findBySender(UUID sender) { - Objects.requireNonNull(sender, "Sender UUID cannot be null"); return this.action( ParcelTable.class, dao -> dao.queryForEq(SENDER_COLUMN, sender).stream() @@ -87,14 +92,11 @@ public CompletableFuture> findBySender(UUID sender) { @Override public CompletableFuture> findBySender(UUID sender, Page page) { - Objects.requireNonNull(sender, "Sender UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); return this.findByPaged(sender, page, SENDER_COLUMN); } @Override public CompletableFuture> findByReceiver(UUID receiver) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); return this.action( ParcelTable.class, dao -> dao.queryForEq(RECEIVER_COLUMN, receiver).stream() @@ -105,17 +107,13 @@ public CompletableFuture> findByReceiver(UUID receiver) { @Override public CompletableFuture> findByReceiver(UUID receiver, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); return this.findByPaged(receiver, page, RECEIVER_COLUMN); } @Override public CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); return this.queryPage(ParcelTable.class, page, builder -> { - var where = builder.where() + Where where = builder.where() .eq(RECEIVER_COLUMN, receiver) .and() .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); @@ -127,23 +125,32 @@ public CompletableFuture> findCollectible(UUID receiver, UUID } @Override - public CompletableFuture markCollected(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); - return this.action(ParcelTable.class, dao -> { - UpdateBuilder builder = dao.updateBuilder(); - builder.updateColumnValue(STATUS_COLUMN, ParcelStatus.COLLECTED); - builder.where() - .eq(UUID_COLUMN, uuid) - .and() - .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); - return builder.update() > 0; - }); + public CompletableFuture commitCollection( + UUID uuid, + UUID receiver, + Instant collectedAt + ) { + 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> findReturnable(UUID receiver, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); return this.queryPage(ParcelTable.class, page, builder -> { builder.where() .eq(RECEIVER_COLUMN, receiver) @@ -155,7 +162,6 @@ public CompletableFuture> findReturnable(UUID receiver, Page @Override public CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker) { - Objects.requireNonNull(destinationLocker, "Destination locker UUID cannot be null"); return this.action(ParcelTable.class, dao -> { long count = dao.queryBuilder() .where() @@ -176,13 +182,11 @@ private CompletableFuture> findByPaged(UUID key, Page page, S @Override public CompletableFuture delete(Parcel parcel) { - Objects.requireNonNull(parcel, "Parcel cannot be null"); return this.delete(parcel.uuid()); } @Override public CompletableFuture delete(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); return this.deleteById(ParcelTable.class, uuid).thenApply(rows -> rows > 0); } @@ -196,7 +200,6 @@ public CompletableFuture> findAll() { @Override public CompletableFuture> findPage(Page page) { - Objects.requireNonNull(page, "Page cannot be null"); return this.queryPage(ParcelTable.class, page, builder -> builder, ParcelTable::toParcel); } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java similarity index 100% rename from src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java similarity index 89% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index 01547f50b..3e0d846fe 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -33,33 +33,31 @@ public AdminParcelService(ParcelService parcelService, ParcelContentManager parc 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()); + /** + * Persists an edited snapshot only if the parcel's status did not change since the snapshot was + * taken, so a stale admin GUI cannot resurrect a parcel that was collected in the meantime. + */ + private CompletableFuture persist(Parcel snapshot, Parcel updated) { + return this.parcelService.updateIfStatus(updated, snapshot.status()) + .thenApply(applied -> applied ? EditResult.ok() : EditResult.of(EditResult.Status.PARCEL_COLLECTED)); } public CompletableFuture changeName(Parcel parcel, String name) { - return this.persist(withName(parcel, name)); + return this.persist(parcel, withName(parcel, name)); } public CompletableFuture changeDescription(Parcel parcel, String description) { - return this.persist(withDescription(parcel, description)); + return this.persist(parcel, withDescription(parcel, description)); } /** @@ -125,16 +123,16 @@ private void scheduleSend(Parcel parcel, Duration delay) { } public CompletableFuture changeReceiver(Parcel parcel, UUID receiver) { - return this.persist(withReceiver(parcel, receiver)); + return this.persist(parcel, 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)) { + if (itemCount > newSize.capacity()) { return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.SIZE_TOO_SMALL)); } - return this.persist(withSize(parcel, newSize)); + return this.persist(parcel, withSize(parcel, newSize)); }); } @@ -143,15 +141,15 @@ public CompletableFuture changeDestination(Parcel parcel, UUID desti if (Boolean.TRUE.equals(full)) { return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.DESTINATION_FULL)); } - return this.persist(withDestination(parcel, destinationLocker)); + return this.persist(parcel, 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.persist(parcel, updated).thenCompose(result -> { + if (!result.isOk() || parcel.status() != ParcelStatus.SENT || newPriority == parcel.priority()) { + return CompletableFuture.completedFuture(result); } return this.deliveryManager.get(parcel.uuid()).thenCompose(optionalDelivery -> { if (optionalDelivery.isEmpty()) { 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..325d10f6f --- /dev/null +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchService.java @@ -0,0 +1,185 @@ +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 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.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class ParcelDispatchService { + + 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; + private final Logger logger; + + // 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, + Logger logger + ) { + this.lockerManager = lockerManager; + this.parcelService = parcelService; + this.deliveryManager = deliveryManager; + this.itemStorageManager = itemStorageManager; + this.scheduler = scheduler; + this.config = config; + this.noticeService = noticeService; + this.logger = logger; + } + + /** + * Sends a parcel: checks the destination capacity, persists the parcel (charging the fee), + * clears the sender's staged item storage and schedules the delivery. + * + * @return a future completed with {@code true} when the parcel was sent, {@code false} when it + * was refused (missing or full locker, cancelled event, insufficient funds), or exceptionally + * when a persistence step failed; a failure after the parcel was saved is rolled back, unless + * the sender's items could not be restored, in which case the parcel is kept so they are not lost + */ + public CompletableFuture dispatch(Player sender, Parcel parcel, List items) { + UUID lockerId = parcel.destinationLocker(); + + CompletableFuture chained = this.lockerChains.compute(lockerId, (id, previous) -> { + CompletableFuture predecessor = previous == null + ? CompletableFuture.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)); + return chained; + } + + private CompletableFuture dispatchInternal(Player sender, Parcel parcel, List items) { + return this.lockerManager.get(parcel.destinationLocker()) + .thenCompose(destination -> { + // The locker may have been removed since it was picked, or never existed for API callers. + if (destination.isEmpty()) { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.destinationNotFound); + return CompletableFuture.completedFuture(false); + } + return this.sendIfLockerHasSpace(sender, parcel, items); + }) + .whenComplete((result, throwable) -> { + if (throwable != null) { + this.logger.log(Level.SEVERE, "Failed to dispatch parcel " + parcel.uuid() + + " for player " + sender.getName(), throwable); + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + } + }); + } + + private CompletableFuture sendIfLockerHasSpace(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(false); + } + + return this.parcelService.send(sender, parcel, items).thenCompose(success -> { + if (!success) { + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + return CompletableFuture.completedFuture(false); + } + return this.clearStorageAndScheduleDelivery(sender, parcel, items); + }); + }); + } + + private CompletableFuture clearStorageAndScheduleDelivery( + Player sender, + Parcel parcel, + List items + ) { + UUID senderId = sender.getUniqueId(); + return this.itemStorageManager.delete(senderId) + // A failed delete must trigger the rollback too, otherwise the parcel would stay sent + // (and the fee charged) while the sender still holds the staged items. + .exceptionally(throwable -> false) + .thenCompose(deleted -> { + if (!deleted) { + return this.rollback(sender, parcel, + new ParcelOperationException("Failed to clear item storage of " + senderId)); + } + + Duration delay = parcel.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + + return this.deliveryManager.create(parcel.uuid(), Instant.now().plus(delay)) + .thenApply(delivery -> { + this.scheduler.runLaterAsync( + new ParcelSendTask(parcel, this.parcelService, this.deliveryManager, this.scheduler), + delay); + // Only confirm success once every step has succeeded, to avoid a "sent" notice + // immediately followed by "cannot send" on a rollback. + this.noticeService.player(senderId, messages -> messages.parcel.sent); + return true; + }) + .exceptionallyCompose(deliveryError -> this.restoreStorageThenRollback(sender, parcel, items, deliveryError)); + }); + } + + private CompletableFuture restoreStorageThenRollback( + Player sender, + Parcel parcel, + List items, + Throwable deliveryError + ) { + return this.itemStorageManager.create(sender.getUniqueId(), items) + .handle((restored, restoreError) -> restoreError) + .thenCompose(restoreError -> { + if (restoreError == null) { + return this.rollback(sender, parcel, + new ParcelOperationException("Failed to create delivery for " + parcel.uuid(), deliveryError)); + } + + // The staged items are gone, so the parcel content is now their only copy - keep the parcel + // instead of rolling it back, so an admin can still deliver or recover it. + ParcelOperationException cause = new ParcelOperationException("Failed to create delivery for " + + parcel.uuid() + " and to restore the sender's items; the parcel and its content were kept" + + " for manual recovery", deliveryError); + cause.addSuppressed(restoreError); + return CompletableFuture.failedFuture(cause); + }); + } + + private CompletableFuture rollback(Player sender, Parcel parcel, ParcelOperationException cause) { + return this.parcelService.rollbackSend(sender, parcel) + .handle((ignored, rollbackError) -> { + if (rollbackError != null) { + cause.addSuppressed(rollbackError); + } + throw cause; + }); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java similarity index 69% rename from src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java rename to parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index fbfe57780..aa68a4728 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -14,20 +14,19 @@ import com.eternalcode.parcellockers.parcel.event.ParcelCollectEvent; import com.eternalcode.parcellockers.parcel.event.ParcelSendEvent; import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; -import com.eternalcode.parcellockers.returns.CollectedParcel; -import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import com.eternalcode.parcellockers.shared.exception.ValidationException; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.base.Preconditions; import java.time.Instant; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import net.milkbowl.vault.economy.Economy; import org.bukkit.Server; @@ -35,7 +34,7 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -public class ParcelServiceImpl implements ParcelService { +public class ParcelServiceImpl implements PluginParcelService { private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; @@ -47,7 +46,6 @@ public class ParcelServiceImpl implements ParcelService { private final NoticeService noticeService; private final ParcelRepository parcelRepository; private final ParcelContentRepository parcelContentRepository; - private final CollectedParcelRepository collectedParcelRepository; private final Scheduler scheduler; private final PluginConfig config; private final Economy economy; @@ -59,7 +57,6 @@ public ParcelServiceImpl( NoticeService noticeService, ParcelRepository parcelRepository, ParcelContentRepository parcelContentRepository, - CollectedParcelRepository collectedParcelRepository, Scheduler scheduler, PluginConfig config, Economy economy, @@ -68,7 +65,6 @@ public ParcelServiceImpl( this.noticeService = noticeService; this.parcelRepository = parcelRepository; this.parcelContentRepository = parcelContentRepository; - this.collectedParcelRepository = collectedParcelRepository; this.scheduler = scheduler; this.config = config; this.economy = economy; @@ -82,77 +78,83 @@ public ParcelServiceImpl( @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); } + double fee = this.feeFor(sender, parcel.size()); + // Check funds before anything is persisted, so the common "cannot afford" case never leaves + // a row behind that would need a compensating delete. + if (fee > 0 && !this.economy.has(sender, fee)) { + this.notifyInsufficientFunds(sender, fee); + 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); - } + // Insert first, so a parcel with an already used UUID is rejected before the fee is charged + // and can never overwrite an existing parcel. + return this.parcelRepository.saveIfAbsent(parcel).thenCompose(inserted -> { + if (!inserted) { + return CompletableFuture.failedFuture( + new ValidationException("Parcel " + parcel.uuid() + " already exists")); + } + return this.chargeAndSaveContent(sender, parcel, itemsCopy, fee); + }); + } - chargedFee = fee; - this.noticeService.create() - .notice(messages -> messages.parcel.feeWithdrawn) - .player(sender.getUniqueId()) - .placeholder(PLACEHOLDER_AMOUNT, formattedFee) - .send(); + private CompletableFuture chargeAndSaveContent( + Player sender, + Parcel parcel, + List items, + double fee + ) { + if (fee > 0) { + // The balance was checked before the insert, but it may have changed since. + if (!this.economy.withdrawPlayer(sender, fee).transactionSuccess()) { + this.notifyInsufficientFunds(sender, fee); + return this.parcelRepository.delete(parcel.uuid()) + .exceptionallyCompose(throwable -> CompletableFuture.failedFuture(new ParcelOperationException( + "Failed to remove parcel " + parcel.uuid() + " after its fee could not be charged", + unwrap(throwable)))) + .thenApply(deleted -> false); } + + this.noticeService.create() + .notice(messages -> messages.parcel.feeWithdrawn) + .player(sender.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, formatFee(fee)) + .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); - }); + return this.parcelContentRepository.save(new ParcelContent(parcel.uuid(), items)) + .thenApply(contentSaved -> { + this.parcelsByUuid.put(parcel.uuid(), parcel); + // The "sent" notice is issued by the dispatcher once the whole send succeeds, so it + // is not shown when a later step (e.g. clearing storage) fails and rolls back. + return true; + }) + .exceptionallyCompose(throwable -> this.parcelRepository.delete(parcel.uuid()) + .handle((deleted, deleteError) -> { + // 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, fee); + this.noticeService.player(sender.getUniqueId(), messages -> messages.parcel.cannotSend); + throw new ParcelOperationException("Failed to save parcel content, rolled back parcel", unwrap(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.refundFee(sender, this.feeFor(sender, parcel.size())); this.parcelsByUuid.invalidate(parcel.uuid()); return this.parcelRepository.delete(parcel.uuid()) @@ -160,6 +162,13 @@ public CompletableFuture rollbackSend(Player sender, Parcel parcel) { .thenApply(contentDeleted -> null); } + private double feeFor(Player sender, ParcelSize size) { + if (sender.hasPermission(PARCEL_FEE_BYPASS_PERMISSION)) { + return 0; + } + return this.feeFor(size); + } + private double feeFor(ParcelSize size) { return switch (size) { case SMALL -> this.config.settings.smallParcelFee; @@ -168,6 +177,18 @@ private double feeFor(ParcelSize size) { }; } + private void notifyInsufficientFunds(Player sender, double fee) { + this.noticeService.create() + .notice(messages -> messages.parcel.insufficientFunds) + .player(sender.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, formatFee(fee)) + .send(); + } + + private static String formatFee(double fee) { + return String.format("%.2f", fee); + } + private void refundFee(Player sender, double fee) { if (fee > 0) { this.economy.depositPlayer(sender, fee); @@ -176,15 +197,12 @@ private void refundFee(Player sender, double 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); @@ -197,8 +215,6 @@ public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus ex @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 -> { @@ -219,13 +235,10 @@ public CompletableFuture delete(CommandSender sender, Parcel parcel) { @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); @@ -241,11 +254,9 @@ public CompletableFuture collect(Player player, Parcel parcel) { CompletableFuture result = new CompletableFuture<>(); // Re-check inventory space on the main thread (the previous async check was a TOCTOU), - // then 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). + // then atomically flip DELIVERED -> COLLECTED for this receiver 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. this.scheduler.run(() -> { if (!canHold(player, items)) { this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.noInventorySpace); @@ -253,11 +264,10 @@ public CompletableFuture collect(Player player, Parcel parcel) { 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.parcelRepository.commitCollection(parcel.uuid(), player.getUniqueId(), Instant.now()) + .thenAccept(committed -> { + if (!committed) { + // Someone else collected it first, or the player is not the receiver. this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); result.complete(null); return; @@ -267,12 +277,13 @@ public CompletableFuture collect(Player player, Parcel parcel) { this.scheduler.run(() -> { items.forEach(item -> ItemUtil.giveItem(player, item)); this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.collected); + result.complete(null); }); - result.complete(null); }) .exceptionally(throwable -> { this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); - result.complete(null); + result.completeExceptionally( + new ParcelOperationException("Failed to collect parcel " + parcel.uuid(), throwable)); return null; }); }); @@ -281,6 +292,14 @@ public CompletableFuture collect(Player player, Parcel parcel) { }); } + // Stages downstream of a failed future see the failure wrapped in a CompletionException. + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof CompletionException && throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable; + } + 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(), @@ -289,8 +308,6 @@ private static Parcel withStatus(Parcel parcel, ParcelStatus 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)); @@ -303,14 +320,11 @@ public CompletableFuture> get(UUID uuid) { @Override public void invalidate(UUID uuid) { - Objects.requireNonNull(uuid, "UUID cannot be null"); this.parcelsByUuid.invalidate(uuid); } @Override public CompletableFuture> getBySender(UUID sender, Page page) { - Objects.requireNonNull(sender, "Sender UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); return this.parcelRepository.findBySender(sender, page) .thenApply(result -> { @@ -321,8 +335,6 @@ public CompletableFuture> getBySender(UUID sender, Page page) @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 -> { @@ -333,8 +345,6 @@ public CompletableFuture> getByReceiver(UUID receiver, Page p @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 -> { @@ -345,8 +355,6 @@ public CompletableFuture> getCollectible(UUID receiver, UUID @Override public CompletableFuture> getReturnable(UUID receiver, Page page) { - Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); - Objects.requireNonNull(page, "Page cannot be null"); return this.parcelRepository.findReturnable(receiver, page) .thenApply(result -> { @@ -357,7 +365,6 @@ public CompletableFuture> getReturnable(UUID receiver, Page p @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)); @@ -367,7 +374,6 @@ public CompletableFuture> getAll(Page page) { @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); @@ -388,14 +394,11 @@ public CompletableFuture delete(UUID uuid) { @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() 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..737b4aaf3 --- /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 java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * Plugin-internal extension of the public {@link ParcelService}, exposing operations that only the + * plugin itself (GUIs, commands, dispatch workflow) is allowed to perform. + */ +public interface PluginParcelService extends ParcelService { + + /** + * Undoes a successful {@link #send(Player, Parcel, java.util.List)}: refunds the sender's fee + * (unless they bypass it) and deletes the parcel together with its content. + * + * @param sender the player who sent the parcel and should be refunded + * @param parcel the parcel to remove + * @return a future completed once the parcel and its content are deleted + */ + CompletableFuture rollbackSend(Player sender, Parcel parcel); + + /** + * Evicts a parcel from the in-memory cache, forcing the next read to hit the database. + * + * @param uuid the parcel to evict + */ + void invalidate(UUID uuid); + + /** + * Deletes a parcel on behalf of a command sender and notifies them about the outcome. + * + * @param sender the viewer who receives the success or failure notice + * @param parcel the parcel to delete + * @return a future completed once the deletion finished; failures are reported to the sender + */ + CompletableFuture delete(CommandSender sender, Parcel parcel); + + /** + * Deletes every parcel and its content, then notifies the sender how many parcels were removed. + * + * @param sender the viewer who receives the notice + * @param noticeService the notice service used to send the summary + * @return a future completed once all parcels and contents are deleted + */ + CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService); +} diff --git a/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/package-info.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/service/package-info.java new file mode 100644 index 000000000..c29a25b06 --- /dev/null +++ b/parcellockers-plugin/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/parcel/task/ParcelSendTask.java b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java similarity index 95% 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..1c0e0628b 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 @@ -85,8 +85,9 @@ private void deliver(Parcel current) { return; } - this.parcelService.update(delivered) - .thenCompose(ignored -> this.deliveryManager.delete(delivered.uuid())) + // Conditional on SENT, so a concurrent admin status change is never overwritten. + this.parcelService.updateIfStatus(delivered, ParcelStatus.SENT) + .thenCompose(applied -> this.deliveryManager.delete(delivered.uuid())) .exceptionally(throwable -> { LOGGER.log(Level.SEVERE, "Failed to deliver parcel " + delivered.uuid() + " (delivery left for retry)", throwable); 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..37b67351b 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, @@ -139,7 +139,7 @@ public CompletableFuture returnParcel(Player player, Parcel parcel, List messages.parcel.cannotReturn); } return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { - if (Boolean.TRUE.equals(isFull)) { + if (isFull) { return this.abort(player, deposited, messages -> messages.parcel.lockerFull); } return this.execute(player, current, deposited, feeBypassed); 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 98% 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 index 46d98e3f4..bca57f2bc 100644 --- a/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java @@ -59,7 +59,7 @@ private void purge(CollectedParcel collected) { // Delete the parcel (and content) first; the row is only removed once that // succeeded so a failed delete is retried on the next run. return this.parcelService.delete(collected.parcel()) - .thenCompose(deleted -> Boolean.TRUE.equals(deleted) + .thenCompose(deleted -> deleted ? this.collectedParcelRepository.delete(collected.parcel()) : CompletableFuture.completedFuture(false)) .thenCompose(unused -> { 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 95% 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 index a21fd788b..d098228d4 100644 --- a/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java +++ b/parcellockers-plugin/src/main/java/com/eternalcode/parcellockers/user/repository/UserRepositoryOrmLite.java @@ -1,5 +1,6 @@ package com.eternalcode.parcellockers.user.repository; +import com.eternalcode.commons.concurrent.FutureHandler; import com.eternalcode.commons.scheduler.Scheduler; import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; @@ -37,7 +38,7 @@ public CompletableFuture> fetch(String name) { public CompletableFuture save(User user) { return this.upsert(UserTable.class, UserTable.from(user)).exceptionally(ex -> { System.err.println("Failed to save user: " + ex.getMessage()); - ex.printStackTrace(); + FutureHandler.handleException(ex); return null; }).thenApply(dao -> null); } 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..9a4b346df --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/PublicParcelServiceTest.java @@ -0,0 +1,226 @@ +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.PluginParcelService; +import com.eternalcode.parcellockers.shared.exception.ValidationException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +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 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 sendRejectsEmptyAndNullElementItemsThroughFailedFuture() { + 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()); + } + verify(fixture.scheduler, never()).runAsync(any()); + } + + @Test + void sendRejectsMoreItemsThanParcelSizeCanHold() { + Fixture fixture = new Fixture(); + List tooMany = Collections.nCopies(fixture.parcel.size().capacity() + 1, mock(ItemStack.class)); + + CompletableFuture result = fixture.service.send(fixture.sender, fixture.parcel, tooMany); + + 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 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()).run(any()); + verify(fixture.delegate, never()).collect(any(), any()); + } + + @Test + void collectStartsDelegateOnMainThread() { + 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).run(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()); + } + + private static Parcel withStatus(Parcel parcel, ParcelStatus status) { + return new Parcel(parcel.uuid(), parcel.sender(), parcel.name(), parcel.description(), + parcel.priority(), parcel.receiver(), parcel.size(), parcel.entryLocker(), + parcel.destinationLocker(), status); + } + + private static final class Fixture { + + private final PluginParcelService delegate = mock(PluginParcelService.class); + private final ParcelDispatchService dispatcher = mock(ParcelDispatchService.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final PublicParcelService service = + new PublicParcelService(this.delegate, this.dispatcher, this.scheduler); + private final Player sender = mock(Player.class); + private final Player receiver = mock(Player.class); + private final Parcel parcel = new Parcel( + UUID.randomUUID(), + UUID.randomUUID(), + "name", + null, + false, + UUID.randomUUID(), + ParcelSize.SMALL, + UUID.randomUUID(), + UUID.randomUUID(), + ParcelStatus.SENT + ); + private final List items = List.of(mock(ItemStack.class)); + + private Fixture() { + when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); + when(this.receiver.getUniqueId()).thenReturn(this.parcel.receiver()); + when(this.items.get(0).clone()).thenReturn(this.items.get(0)); + } + } +} diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/TestItemStacks.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/TestItemStacks.java new file mode 100644 index 000000000..6e0166565 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/TestItemStacks.java @@ -0,0 +1,47 @@ +package com.eternalcode.parcellockers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.UnsafeValues; +import org.bukkit.inventory.ItemStack; + +/** + * Real {@link ItemStack}s need a running server (item registry), so persistence tests use mocks + * serialized to a single marker byte and resolved back through a mocked {@link UnsafeValues}. + */ +public final class TestItemStacks { + + private static final String BUKKIT_SERVER_FIELD = "server"; + + private TestItemStacks() { + } + + public static ItemStack stack(byte marker) { + ItemStack item = mock(ItemStack.class); + when(item.isEmpty()).thenReturn(false); + when(item.serializeAsBytes()).thenReturn(new byte[] { marker }); + return item; + } + + public static void installDeserializer(Map itemsByMarker) throws ReflectiveOperationException { + Map items = Map.copyOf(itemsByMarker); + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeItem(any(byte[].class))).thenAnswer(invocation -> { + byte[] serialized = invocation.getArgument(0); + return items.get(serialized[0]); + }); + + Server server = mock(Server.class); + when(server.getUnsafe()).thenReturn(unsafe); + + Field serverField = Bukkit.class.getDeclaredField(BUKKIT_SERVER_FIELD); + serverField.setAccessible(true); + serverField.set(null, server); + } +} 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 80% rename from src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java index ef28c000b..5c83e6d68 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java @@ -20,16 +20,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class CollectedParcelRepositoryIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class CollectedParcelRepositoryIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -37,13 +29,7 @@ class CollectedParcelRepositoryIntegrationTest extends IntegrationTestSpec { private DatabaseManager databaseManager; private CollectedParcelRepository repository() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); 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 68% rename from src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java index 98183f431..b314278f4 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRenameIntegrationTest.java @@ -17,16 +17,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class LockerRenameIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class LockerRenameIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -35,13 +27,7 @@ class LockerRenameIntegrationTest extends IntegrationTestSpec { @Test void updateOverwritesExistingName() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); 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 71% rename from src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java index a4c7cd08b..345dc4de5 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/LockerRepositoryIntegrationTest.java @@ -20,16 +20,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class LockerRepositoryIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class LockerRepositoryIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -38,13 +30,7 @@ class LockerRepositoryIntegrationTest extends IntegrationTestSpec { @Test void test() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); @@ -57,7 +43,7 @@ void test() throws SQLException { Position position = new Position(1, 2, 3, "world"); - parcelLockerRepository.save(new Locker(uuid, description, position)); + this.await(parcelLockerRepository.save(new Locker(uuid, description, position))); Optional parcelLocker = this.await(parcelLockerRepository.find(uuid)); assertTrue(parcelLocker.isPresent()); diff --git a/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/MySqlIntegrationTestSpec.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/MySqlIntegrationTestSpec.java new file mode 100644 index 000000000..5ad65b7df --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/MySqlIntegrationTestSpec.java @@ -0,0 +1,67 @@ +package com.eternalcode.parcellockers.database; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Shares a single MySQL container across all integration test classes (singleton container pattern). + * The container is started lazily once per JVM and stopped by Testcontainers' Ryuk on JVM exit. + * Every test starts with an empty schema, so test classes stay isolated from each other. + */ +@Testcontainers(disabledWithoutDocker = true) +abstract class MySqlIntegrationTestSpec extends IntegrationTestSpec { + + private static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8.4"); + private static final MySQLContainer MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_IMAGE); + + private static final String LIST_TABLES_QUERY = + "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()"; + + @BeforeAll + static void startContainer() { + MYSQL_CONTAINER.start(); // no-op when already running + } + + @BeforeEach + void cleanDatabase() throws SQLException { + try (Connection connection = DriverManager.getConnection( + MYSQL_CONTAINER.getJdbcUrl(), MYSQL_CONTAINER.getUsername(), MYSQL_CONTAINER.getPassword()); + Statement statement = connection.createStatement()) { + + List tables = new ArrayList<>(); + try (ResultSet resultSet = statement.executeQuery(LIST_TABLES_QUERY)) { + while (resultSet.next()) { + tables.add(resultSet.getString(1)); + } + } + + statement.execute("SET FOREIGN_KEY_CHECKS = 0"); + for (String table : tables) { + statement.execute("DROP TABLE `" + table + "`"); + } + statement.execute("SET FOREIGN_KEY_CHECKS = 1"); + } + } + + PluginConfig mysqlConfig() { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.MYSQL; + config.settings.host = MYSQL_CONTAINER.getHost(); + config.settings.port = String.valueOf(MYSQL_CONTAINER.getFirstMappedPort()); + config.settings.databaseName = MYSQL_CONTAINER.getDatabaseName(); + config.settings.user = MYSQL_CONTAINER.getUsername(); + config.settings.password = MYSQL_CONTAINER.getPassword(); + return config; + } +} 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 58% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java index 5de5aa24f..9de327e4f 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelContentUpdateIntegrationTest.java @@ -1,7 +1,10 @@ package com.eternalcode.parcellockers.database; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static com.eternalcode.parcellockers.TestItemStacks.installDeserializer; +import static com.eternalcode.parcellockers.TestItemStacks.stack; import com.eternalcode.parcellockers.TestScheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; @@ -11,24 +14,19 @@ import java.nio.file.Path; import java.sql.SQLException; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.logging.Logger; -import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class ParcelContentUpdateIntegrationTest extends IntegrationTestSpec { +class ParcelContentUpdateIntegrationTest extends MySqlIntegrationTestSpec { - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + private static final byte ORIGINAL_MARKER = 1; + private static final byte UPDATED_MARKER = 2; @TempDir private Path tempDir; @@ -36,30 +34,27 @@ class ParcelContentUpdateIntegrationTest extends IntegrationTestSpec { private DatabaseManager databaseManager; @Test - void updateOverwritesExistingContent() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + void updateOverwritesExistingContent() throws SQLException, ReflectiveOperationException { + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); this.databaseManager = databaseManager; ParcelContentRepository repository = new ParcelContentRepositoryOrmLite(databaseManager, new TestScheduler()); + ItemStack originalStack = stack(ORIGINAL_MARKER); + ItemStack updatedStack = stack(UPDATED_MARKER); + installDeserializer(Map.of(ORIGINAL_MARKER, originalStack, UPDATED_MARKER, updatedStack)); + UUID parcel = UUID.randomUUID(); - this.await(repository.save(new ParcelContent(parcel, List.of(new ItemStack(Material.STONE, 1))))); + this.await(repository.save(new ParcelContent(parcel, List.of(originalStack)))); - this.await(repository.update(new ParcelContent(parcel, List.of(new ItemStack(Material.DIAMOND, 5))))); + this.await(repository.update(new ParcelContent(parcel, List.of(updatedStack)))); Optional reloaded = this.await(repository.find(parcel)); assertTrue(reloaded.isPresent()); assertEquals(1, reloaded.get().items().size()); - assertEquals(Material.DIAMOND, reloaded.get().items().getFirst().getType()); - assertEquals(5, reloaded.get().items().getFirst().getAmount()); + assertSame(updatedStack, reloaded.get().items().getFirst()); } @AfterEach 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 82% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java index 87a2da64f..3b0e6541d 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindCollectibleIntegrationTest.java @@ -20,16 +20,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class ParcelFindCollectibleIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class ParcelFindCollectibleIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -37,13 +29,7 @@ class ParcelFindCollectibleIntegrationTest extends IntegrationTestSpec { private DatabaseManager databaseManager; private ParcelRepository repository() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); 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 71% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java index 21ebcda24..ef6f89631 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelFindPageIntegrationTest.java @@ -20,16 +20,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class ParcelFindPageIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class ParcelFindPageIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -38,13 +30,7 @@ class ParcelFindPageIntegrationTest extends IntegrationTestSpec { @Test void findPageReturnsAllParcelsAcrossSenders() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); 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 78% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java index 4892af966..861aecbf5 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java @@ -21,16 +21,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class ParcelRepositoryIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class ParcelRepositoryIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -39,13 +31,7 @@ class ParcelRepositoryIntegrationTest extends IntegrationTestSpec { @Test void test() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); @@ -58,8 +44,8 @@ void test() throws SQLException { UUID entryLocker = UUID.randomUUID(); UUID destinationLocker = UUID.randomUUID(); - parcelRepository.save(new Parcel(uuid, sender, "name", "description", true, receiver, - ParcelSize.SMALL, entryLocker, destinationLocker, ParcelStatus.SENT)); + this.await(parcelRepository.save(new Parcel(uuid, sender, "name", "description", true, receiver, + ParcelSize.SMALL, entryLocker, destinationLocker, ParcelStatus.SENT))); Optional parcel = this.await(parcelRepository.findById(uuid)); assertTrue(parcel.isPresent()); 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 87% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java index 150ebe085..6d04fa9ce 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java @@ -5,9 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static com.eternalcode.parcellockers.TestItemStacks.installDeserializer; +import static com.eternalcode.parcellockers.TestItemStacks.stack; import com.eternalcode.parcellockers.TestScheduler; import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; @@ -28,7 +27,6 @@ import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepositoryOrmLite; import java.io.File; -import java.lang.reflect.Field; import java.sql.SQLException; import java.time.Instant; import java.util.List; @@ -36,9 +34,6 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; -import org.bukkit.Bukkit; -import org.bukkit.Server; -import org.bukkit.UnsafeValues; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -156,27 +151,6 @@ private static ParcelContent content(UUID parcel, ItemStack item) { return new ParcelContent(parcel, List.of(item)); } - private static ItemStack stack(byte marker) { - ItemStack item = mock(ItemStack.class); - when(item.isEmpty()).thenReturn(false); - when(item.serializeAsBytes()).thenReturn(new byte[] { marker }); - return item; - } - - private static void installDeserializer(Map items) - throws ReflectiveOperationException { - UnsafeValues unsafe = mock(UnsafeValues.class); - when(unsafe.deserializeItem(any(byte[].class))).thenAnswer(invocation -> { - byte[] serialized = invocation.getArgument(0); - return items.get(serialized[0]); - }); - Server server = mock(Server.class); - when(server.getUnsafe()).thenReturn(unsafe); - Field serverField = Bukkit.class.getDeclaredField("server"); - serverField.setAccessible(true); - serverField.set(null, server); - } - @AfterEach void tearDown() { if (this.scheduler != null) { 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 73% rename from src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java index dc5dc7e52..884ff53e8 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java @@ -11,25 +11,19 @@ import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import java.nio.file.Path; import java.sql.SQLException; +import java.time.Instant; import java.util.UUID; import java.util.logging.Logger; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -@Testcontainers(disabledWithoutDocker = true) -class ParcelReturnRepositoryIntegrationTest extends IntegrationTestSpec { - - @Container - private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); +class ParcelReturnRepositoryIntegrationTest extends MySqlIntegrationTestSpec { @TempDir private Path tempDir; @@ -37,13 +31,7 @@ class ParcelReturnRepositoryIntegrationTest extends IntegrationTestSpec { private DatabaseManager databaseManager; private ParcelRepository repository() throws SQLException { - PluginConfig config = new PluginConfig(); - config.settings.databaseType = DatabaseType.MYSQL; - config.settings.host = mySQLContainer.getHost(); - config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); - config.settings.databaseName = mySQLContainer.getDatabaseName(); - config.settings.user = mySQLContainer.getUsername(); - config.settings.password = mySQLContainer.getPassword(); + PluginConfig config = this.mysqlConfig(); DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); databaseManager.connect(); @@ -58,20 +46,25 @@ private static Parcel parcel(UUID receiver, UUID destinationLocker, ParcelStatus } @Test - void markCollectedFlipsOnlyDeliveredParcels() throws SQLException { + void commitCollectionFlipsOnlyDeliveredParcelsOfReceiver() throws SQLException { ParcelRepository repository = this.repository(); - Parcel delivered = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.DELIVERED); + new CollectedParcelRepositoryOrmLite(this.databaseManager, new TestScheduler()); + UUID receiver = UUID.randomUUID(); + Parcel delivered = parcel(receiver, UUID.randomUUID(), ParcelStatus.DELIVERED); this.await(repository.save(delivered)); - assertTrue(this.await(repository.markCollected(delivered.uuid()))); + // A player who is not the receiver cannot collect the parcel. + assertFalse(this.await(repository.commitCollection(delivered.uuid(), UUID.randomUUID(), Instant.now()))); + + assertTrue(this.await(repository.commitCollection(delivered.uuid(), receiver, Instant.now()))); assertEquals(ParcelStatus.COLLECTED, this.await(repository.findById(delivered.uuid())).orElseThrow().status()); // Second collect attempt must not report success — this is the double-collect guard. - assertFalse(this.await(repository.markCollected(delivered.uuid()))); + assertFalse(this.await(repository.commitCollection(delivered.uuid(), receiver, Instant.now()))); - Parcel sent = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.SENT); + Parcel sent = parcel(receiver, UUID.randomUUID(), ParcelStatus.SENT); this.await(repository.save(sent)); - assertFalse(this.await(repository.markCollected(sent.uuid()))); + assertFalse(this.await(repository.commitCollection(sent.uuid(), receiver, Instant.now()))); } @Test 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/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/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java similarity index 72% rename from src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java index aa33f5a2f..12433829d 100644 --- a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -1,6 +1,12 @@ package com.eternalcode.parcellockers.parcel.service; 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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; @@ -8,6 +14,7 @@ import java.time.Duration; import java.time.Instant; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.Test; class AdminParcelServiceTest { @@ -30,11 +37,26 @@ void changeStatusRefusesCollectedParcelWithoutTouchingAnyCollaborator() { assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); } + @Test + void editOfStaleSnapshotIsRefusedWhenStatusChangedMeanwhile() { + Parcel staleSent = new Parcel(UUID.randomUUID(), UUID.randomUUID(), "name", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.SENT); + ParcelService parcelService = mock(ParcelService.class); + when(parcelService.updateIfStatus(any(), any())).thenReturn(CompletableFuture.completedFuture(false)); + AdminParcelService service = new AdminParcelService(parcelService, null, null, null, null, null); + + EditResult result = service.changeName(staleSent, "edited").join(); + + assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); + verify(parcelService).updateIfStatus(any(), eq(ParcelStatus.SENT)); + verify(parcelService, never()).update(any()); + } + @Test void capacityMatchesContentGuiUsableSlots() { - assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); - assertEquals(18, AdminParcelService.capacity(ParcelSize.MEDIUM)); - assertEquals(27, AdminParcelService.capacity(ParcelSize.LARGE)); + assertEquals(9, ParcelSize.SMALL.capacity()); + assertEquals(18, ParcelSize.MEDIUM.capacity()); + assertEquals(27, ParcelSize.LARGE.capacity()); } @Test 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..24b111cf9 --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelDispatchServiceTest.java @@ -0,0 +1,181 @@ +package com.eternalcode.parcellockers.parcel.service; + +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.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.itemstorage.ItemStorage; +import com.eternalcode.parcellockers.itemstorage.ItemStorageManager; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +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; +import org.mockito.InOrder; + +class ParcelDispatchServiceTest { + + private final LockerManager lockerManager = mock(LockerManager.class); + private final PluginParcelService parcelService = mock(PluginParcelService.class); + private final DeliveryManager deliveryManager = mock(DeliveryManager.class); + private final ItemStorageManager itemStorageManager = mock(ItemStorageManager.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final NoticeService noticeService = mock(NoticeService.class); + private final PluginConfig config = new PluginConfig(); + private final Player sender = mock(Player.class); + private final Parcel parcel = 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 final ParcelDispatchService dispatcher = new ParcelDispatchService( + this.lockerManager, + this.parcelService, + this.deliveryManager, + this.itemStorageManager, + this.scheduler, + this.config, + this.noticeService, + Logger.getLogger(ParcelDispatchServiceTest.class.getName()) + ); + + @BeforeEach + void setUp() { + this.config.settings.parcelSendDuration = Duration.ofMinutes(30); + when(this.sender.getUniqueId()).thenReturn(this.parcel.sender()); + when(this.lockerManager.get(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(Locker.class)))); + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(true)); + when(this.parcelService.rollbackSend(this.sender, this.parcel)) + .thenReturn(CompletableFuture.completedFuture(null)); + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.completedFuture(true)); + } + + @Test + void successCompletesOnlyAfterDeliveryIsPersistedAndTaskIsScheduled() { + CompletableFuture deliveryCreated = new CompletableFuture<>(); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))).thenReturn(deliveryCreated); + + CompletableFuture result = this.dispatcher.dispatch(this.sender, this.parcel, this.items); + + assertFalse(result.isDone()); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + + deliveryCreated.complete(mock(Delivery.class)); + + assertTrue(result.join()); + verify(this.scheduler).runLaterAsync(any(), eq(this.config.settings.parcelSendDuration)); + } + + @Test + void fullLockerCompletesFalseWithoutPersistingParcel() { + when(this.lockerManager.isLockerFull(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(true)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.parcelService, never()).send(any(), any(), any()); + } + + @Test + void missingDestinationLockerCompletesFalseWithoutPersistingParcel() { + when(this.lockerManager.get(this.parcel.destinationLocker())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.parcelService, never()).send(any(), any(), any()); + } + + @Test + void refusedSendCompletesFalseWithoutTouchingStorage() { + when(this.parcelService.send(this.sender, this.parcel, this.items)) + .thenReturn(CompletableFuture.completedFuture(false)); + + assertFalse(this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + verify(this.itemStorageManager, never()).delete(any()); + } + + @Test + void storageDeleteFailureRollsBackAndCompletesExceptionally() { + when(this.itemStorageManager.delete(this.parcel.sender())) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("delete failed"))); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.deliveryManager, never()).create(any(), any()); + } + + @Test + void deliveryCreateFailureRestoresStorageThenRollsBack() { + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("delivery failed"))); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + .thenReturn(CompletableFuture.completedFuture(new ItemStorage(this.parcel.sender(), this.items))); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + InOrder order = inOrder(this.itemStorageManager, this.parcelService); + order.verify(this.itemStorageManager).create(this.parcel.sender(), this.items); + order.verify(this.parcelService).rollbackSend(this.sender, this.parcel); + verify(this.scheduler, never()).runLaterAsync(any(), any()); + } + + @Test + void failedStorageRestoreKeepsParcelInsteadOfRollingBack() { + IllegalStateException restoreFailure = new IllegalStateException("restore failed"); + when(this.deliveryManager.create(eq(this.parcel.uuid()), any(Instant.class))) + .thenReturn(CompletableFuture.failedFuture(new IllegalStateException("delivery failed"))); + when(this.itemStorageManager.create(this.parcel.sender(), this.items)) + .thenReturn(CompletableFuture.failedFuture(restoreFailure)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.dispatcher.dispatch(this.sender, this.parcel, this.items).join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(restoreFailure, unwrap(operationException.getSuppressed()[0])); + verify(this.parcelService, never()).rollbackSend(any(), any()); + } + + private static Throwable unwrap(Throwable throwable) { + return throwable instanceof CompletionException && throwable.getCause() != null + ? throwable.getCause() + : throwable; + } +} 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..16eeb691c --- /dev/null +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImplTest.java @@ -0,0 +1,240 @@ +package com.eternalcode.parcellockers.parcel.service; + +import static com.eternalcode.parcellockers.util.InventoryUtil.canHold; +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.RETURNS_SELF; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.NoticeBroadcast; +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.repository.ParcelRepository; +import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; +import com.eternalcode.parcellockers.shared.exception.ValidationException; +import com.eternalcode.parcellockers.util.InventoryUtil; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Optional; +import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import 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.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class ParcelServiceImplTest { + + private static final double FEE = 10.0; + + private final NoticeService noticeService = mock(NoticeService.class); + // Deep stubs cannot follow the self-typed fluent builder, so the broadcast returns itself explicitly. + private final NoticeBroadcast noticeBroadcast = mock(NoticeBroadcast.class, RETURNS_SELF); + private final ParcelRepository parcelRepository = mock(ParcelRepository.class); + private final ParcelContentRepository contentRepository = mock(ParcelContentRepository.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final Economy economy = mock(Economy.class); + private final Server server = mock(Server.class); + private final Player player = mock(Player.class); + private final UUID playerId = UUID.randomUUID(); + private final Queue mainTasks = new ArrayDeque<>(); + private final ParcelServiceImpl service; + + ParcelServiceImplTest() { + PluginConfig config = new PluginConfig(); + config.settings.smallParcelFee = FEE; + + when(this.server.getPluginManager()).thenReturn(mock(PluginManager.class)); + when(this.player.getUniqueId()).thenReturn(this.playerId); + when(this.economy.has(this.player, FEE)).thenReturn(true); + doReturn(this.noticeBroadcast).when(this.noticeService).create(); + doAnswer(invocation -> { + this.mainTasks.add(invocation.getArgument(0)); + return null; // Scheduler#run returns a Task, which the service never uses + }).when(this.scheduler).run(any(Runnable.class)); + + this.service = new ParcelServiceImpl( + this.noticeService, this.parcelRepository, this.contentRepository, + this.scheduler, config, this.economy, this.server); + } + + @Test + void duplicateParcelUuidFailsBeforeChargeAndDoesNotDeleteExistingParcel() { + Parcel parcel = this.parcel(ParcelStatus.SENT); + when(this.parcelRepository.saveIfAbsent(parcel)).thenReturn(CompletableFuture.completedFuture(false)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.service.send(this.player, parcel, List.of(mock(ItemStack.class))).join()); + + assertInstanceOf(ValidationException.class, exception.getCause()); + verify(this.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + verify(this.contentRepository, never()).save(any()); + verify(this.parcelRepository, never()).delete(parcel.uuid()); + } + + @Test + void insufficientFundsRefusesBeforeAnythingIsPersisted() { + Parcel parcel = this.parcel(ParcelStatus.SENT); + when(this.economy.has(this.player, FEE)).thenReturn(false); + + assertFalse(this.service.send(this.player, parcel, List.of(mock(ItemStack.class))).join()); + + verify(this.parcelRepository, never()).saveIfAbsent(any()); + verify(this.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + verify(this.contentRepository, never()).save(any()); + } + + @Test + void failedWithdrawalAfterInsertRemovesParcelWithoutSavingContent() { + Parcel parcel = this.parcel(ParcelStatus.SENT); + when(this.parcelRepository.saveIfAbsent(parcel)).thenReturn(CompletableFuture.completedFuture(true)); + when(this.economy.withdrawPlayer(this.player, FEE)).thenReturn(response(EconomyResponse.ResponseType.FAILURE)); + when(this.parcelRepository.delete(parcel.uuid())).thenReturn(CompletableFuture.completedFuture(true)); + + assertFalse(this.service.send(this.player, parcel, List.of(mock(ItemStack.class))).join()); + + verify(this.parcelRepository).delete(parcel.uuid()); + verify(this.contentRepository, never()).save(any()); + } + + @Test + void failedCleanupAfterFailedWithdrawalCompletesExceptionally() { + Parcel parcel = this.parcel(ParcelStatus.SENT); + IllegalStateException deleteFailure = new IllegalStateException("delete failed"); + when(this.parcelRepository.saveIfAbsent(parcel)).thenReturn(CompletableFuture.completedFuture(true)); + when(this.economy.withdrawPlayer(this.player, FEE)).thenReturn(response(EconomyResponse.ResponseType.FAILURE)); + when(this.parcelRepository.delete(parcel.uuid())).thenReturn(CompletableFuture.failedFuture(deleteFailure)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.service.send(this.player, parcel, List.of(mock(ItemStack.class))).join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(deleteFailure, operationException.getCause()); + } + + @Test + void contentPersistenceFailureDeletesParcelAndRefundsFee() { + Parcel parcel = this.parcel(ParcelStatus.SENT); + IllegalStateException contentSaveFailure = new IllegalStateException("content save failed"); + when(this.parcelRepository.saveIfAbsent(parcel)).thenReturn(CompletableFuture.completedFuture(true)); + when(this.economy.withdrawPlayer(this.player, FEE)).thenReturn(response(EconomyResponse.ResponseType.SUCCESS)); + when(this.contentRepository.save(any())).thenReturn(CompletableFuture.failedFuture(contentSaveFailure)); + when(this.parcelRepository.delete(parcel.uuid())).thenReturn(CompletableFuture.completedFuture(true)); + + CompletionException exception = assertThrows(CompletionException.class, + () -> this.service.send(this.player, parcel, List.of(mock(ItemStack.class))).join()); + + ParcelOperationException operationException = + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + assertSame(contentSaveFailure, operationException.getCause()); + verify(this.parcelRepository).delete(parcel.uuid()); + verify(this.economy).depositPlayer(this.player, FEE); + } + + @Test + void collectCompletesOnlyAfterItemsWereGivenOnMainThread() { + Parcel parcel = this.parcel(ParcelStatus.DELIVERED); + ItemStack item = mock(ItemStack.class); + this.stubContent(parcel, item); + when(this.parcelRepository.commitCollection(eq(parcel.uuid()), eq(this.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + try (MockedStatic inventory = mockStatic(InventoryUtil.class); + MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + inventory.when(() -> canHold(this.player, List.of(item))).thenReturn(true); + + CompletableFuture result = this.service.collect(this.player, parcel); + + this.runNextMainTask(); + assertFalse(result.isDone()); + this.runNextMainTask(); + itemUtil.verify(() -> ItemUtil.giveItem(this.player, item)); + assertTrue(result.isDone()); + } + } + + @Test + void collectDoesNotGiveItemsWhenCollectionWasNotCommitted() { + Parcel parcel = this.parcel(ParcelStatus.DELIVERED); + ItemStack item = mock(ItemStack.class); + this.stubContent(parcel, item); + when(this.parcelRepository.commitCollection(eq(parcel.uuid()), eq(this.playerId), any())) + .thenReturn(CompletableFuture.completedFuture(false)); + + try (MockedStatic inventory = mockStatic(InventoryUtil.class); + MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + inventory.when(() -> canHold(this.player, List.of(item))).thenReturn(true); + + CompletableFuture result = this.service.collect(this.player, parcel); + this.runNextMainTask(); + + assertTrue(result.isDone()); + assertTrue(this.mainTasks.isEmpty()); + itemUtil.verifyNoInteractions(); + } + } + + @Test + void collectPersistenceFailureCompletesExceptionally() { + Parcel parcel = this.parcel(ParcelStatus.DELIVERED); + IllegalStateException databaseFailure = new IllegalStateException("database failed"); + this.stubContent(parcel); + when(this.parcelRepository.commitCollection(eq(parcel.uuid()), eq(this.playerId), any())) + .thenReturn(CompletableFuture.failedFuture(databaseFailure)); + + try (MockedStatic inventory = mockStatic(InventoryUtil.class)) { + inventory.when(() -> canHold(this.player, List.of())).thenReturn(true); + + CompletableFuture result = this.service.collect(this.player, parcel); + this.runNextMainTask(); + + CompletionException exception = assertThrows(CompletionException.class, result::join); + assertInstanceOf(ParcelOperationException.class, exception.getCause()); + } + } + + private void stubContent(Parcel parcel, ItemStack... items) { + ParcelContent content = new ParcelContent(parcel.uuid(), List.of(items)); + when(this.contentRepository.find(parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(content))); + } + + private void runNextMainTask() { + this.mainTasks.remove().run(); + } + + private Parcel parcel(ParcelStatus status) { + return new Parcel(UUID.randomUUID(), this.playerId, "name", null, false, this.playerId, + ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), status); + } + + private static EconomyResponse response(EconomyResponse.ResponseType type) { + return new EconomyResponse(FEE, 100.0, type, null); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java similarity index 56% rename from src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java rename to parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java index 29014a276..35c0ca113 100644 --- a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java +++ b/parcellockers-plugin/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java @@ -1,20 +1,64 @@ 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.ParcelService; import java.time.Duration; import java.time.Instant; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; class ParcelSendTaskTest { + @Test + void deliversOnlyIfParcelIsStillSent() { + Parcel sent = parcel(ParcelStatus.SENT); + Delivery due = new Delivery(sent.uuid(), Instant.now().minusSeconds(1)); + ParcelService parcelService = mock(ParcelService.class); + DeliveryManager deliveryManager = mock(DeliveryManager.class); + when(parcelService.get(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(sent))); + when(deliveryManager.get(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(due))); + when(parcelService.updateIfStatus(any(), eq(ParcelStatus.SENT))) + .thenReturn(CompletableFuture.completedFuture(true)); + when(deliveryManager.delete(sent.uuid())) + .thenReturn(CompletableFuture.completedFuture(true)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + + new ParcelSendTask(sent, parcelService, deliveryManager, mock(Scheduler.class)).run(); + } + + verify(parcelService).updateIfStatus( + new Parcel(sent.uuid(), sent.sender(), sent.name(), sent.description(), + sent.priority(), sent.receiver(), sent.size(), sent.entryLocker(), + sent.destinationLocker(), ParcelStatus.DELIVERED), + ParcelStatus.SENT); + verify(parcelService, never()).update(any()); + verify(deliveryManager).delete(sent.uuid()); + } + private static Parcel parcel(ParcelStatus status) { UUID id = UUID.randomUUID(); return new Parcel(id, UUID.randomUUID(), "n", "d", false, UUID.randomUUID(), 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/parcel/Parcel.java b/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java deleted file mode 100644 index c248c7ed4..000000000 --- a/src/main/java/com/eternalcode/parcellockers/parcel/Parcel.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.eternalcode.parcellockers.parcel; - -import java.util.UUID; - -public record Parcel( - UUID uuid, - UUID sender, - String name, - String description, - boolean priority, - UUID receiver, - ParcelSize size, - UUID entryLocker, - UUID destinationLocker, - ParcelStatus status -) { -} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java b/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java deleted file mode 100644 index 7edef2bd9..000000000 --- a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelSize.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.eternalcode.parcellockers.parcel; - -public enum ParcelSize { - - SMALL, - MEDIUM, - LARGE -} \ No newline at end of file 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; - }); - } -}