diff --git a/.gitignore b/.gitignore index ccd8aeb3..2fcfe81a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,12 @@ run/ out/ build/ +.claude/ +.codex/ +.gemini/ +graphify-out/ .DS_Store [Dd]esktop.ini !gradle/wrapper/gradle-wrapper.jar -.claude diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4f7cf18e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +# Build the plugin JAR (output: build/libs/ParcelLockers v.jar) +./gradlew shadowJar + +# Run tests +./gradlew test + +# Run a single test class +./gradlew test --tests "com.eternalcode.parcellockers.database.ParcelRepositoryIntegrationTest" + +# Start a local Paper server with the plugin loaded (downloads Paper + dependencies automatically) +./gradlew runServer +``` + +Requires JDK 21+. The `runServer` task uses JetBrains JVM and auto-downloads LuckPerms, VaultUnlocked, and EssentialsX. Uncomment the DiscordSRV line in `build.gradle.kts` to test that integration locally. + +## Architecture Overview + +ParcelLockers is a Paper plugin (Minecraft 1.21) that lets players transfer items between parcel locker blocks across the world, with optional Discord notifications. + +### Entry Point & Wiring + +`ParcelLockers.java` (`onEnable`) is the manual DI root — all components are instantiated and wired there in order: config → database → repositories → managers/services → GUIs → commands → event controllers. There is no DI framework; dependencies are passed via constructors. + +### Domain Layers + +Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, `discord`) follows a consistent layered structure: + +- **Model** — plain record/class (e.g. `Locker`, `Parcel`, `User`) +- **Repository** — interface + `*OrmLite` implementation backed by H2/PostgreSQL via ORMLite +- **Manager/Service** — business logic, coordinates repository calls; repositories return `CompletableFuture` for all async DB operations +- **Controller** — Bukkit `Listener` handling in-game events (block place/break/interact, player join/quit) + +### Key Components + +| Component | Purpose | +|---|---| +| `DatabaseManager` | Manages HikariCP connection pool; supports H2 (default, embedded) and PostgreSQL | +| `ConfigService` + okaeri-configs | Loads `config.yml` and `messages.yml` via YAML; `PluginConfig` and `MessageConfig` are the config POJOs | +| `NoticeService` + multification | Sends MiniMessage-formatted notices to players; all user-facing text goes through `MessageConfig` | +| `ParcelDispatchService` | Orchestrates sending a parcel: validates, charges economy (Vault), schedules `ParcelSendTask` | +| `ParcelSendTask` | Runs async after a configurable delay; marks parcel DELIVERED and fires the deliver notification event | +| `GuiManager` + triumph-gui | Factory for all inventory GUIs; `LockerGui` and `MainGui` are the two root GUI entry points | +| `DiscordProviderPicker` | Selects between Discord4J (standalone bot) and DiscordSRV (delegation) at startup based on detected plugins | +| `LockerPlaceController` | Uses Paper's Dialog API (unstable) to prompt for a locker description when a player places the locker item | + +### Optional Integrations + +- **DiscordSRV** — when present, account linking and DM notifications are delegated to it; otherwise, the plugin manages its own Discord bot via Discord4J +- **Nexo** — when present, custom item blocks can be used as locker blocks (`NexoIntegration.placeBlock`); guarded by `isPluginEnabled("Nexo")` checks +- **Vault** — required; used to charge players an economy fee when sending parcels + +### Testing + +Tests live in `src/test/java/`. Integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `IntegrationTestSpec` and use Testcontainers (MySQL) to test repository implementations against a real database. `ParcelPageTest` is a unit test with no container dependency. diff --git a/CLAUDE.md b/CLAUDE.md index 0a7b1a57..071edc2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,3 +59,13 @@ Each domain (`locker`, `parcel`, `content`, `delivery`, `itemstorage`, `user`, ` ### Testing Tests live in `src/test/java/`. Integration tests (e.g. `LockerRepositoryIntegrationTest`) extend `IntegrationTestSpec` and use Testcontainers (MySQL) to test repository implementations against a real database. `ParcelPageTest` is a unit test with no container dependency. + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/build.gradle.kts b/build.gradle.kts index 8f05d11c..f72cc4a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -92,8 +92,17 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter:${Versions.TESTCONTAINERS}") testImplementation("org.testcontainers:mysql:${Versions.TESTCONTAINERS}") + testImplementation("com.zaxxer:HikariCP:${Versions.HIKARICP}") + testImplementation("com.j256.ormlite:ormlite-jdbc:${Versions.ORMLITE}") + testImplementation("com.h2database:h2:${Versions.H2}") testImplementation("mysql:mysql-connector-java:${Versions.MYSQL_CONNECTOR}") + testImplementation("de.eldoria.jacksonbukkit:paper:${Versions.JACKSON_BUKKIT}") + testImplementation("com.eternalcode:eternalcode-commons-adventure:${Versions.ETERNALCODE_COMMONS}") + testImplementation("com.eternalcode:eternalcode-commons-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}") @@ -145,7 +154,7 @@ modrinth { versionNumber.set(project.version.toString()) versionType.set(getVersionType(project.version.toString())) uploadFile.set(tasks.shadowJar) - gameVersions.addAll("1.21.11") + gameVersions.addAll("1.21.11", "26.1", "26.1.1", "26.1.2", "26.2") loaders.addAll("paper", "purpur") syncBodyFrom = rootProject.file("README.md").readText() } diff --git a/docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md b/docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md new file mode 100644 index 00000000..86694d23 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-parcel-return-mismatch-details.md @@ -0,0 +1,368 @@ +# Detailed Parcel Return Mismatch Messages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the generic refunded-parcel content mismatch notice with one configurable notice that lists every item-specific mismatch category. + +**Architecture:** `ReturnItemEquivalence` becomes a diagnostic comparator while preserving predicate behavior. `ParcelReturnValidator` returns immutable diagnostics, `ReturnMismatchFormatter` renders them through `MessageConfig`, and `ParcelReturnService` sends one formatted notice while retaining the current give-back and atomic-return boundaries. + +**Tech Stack:** Java 21, Paper/Bukkit, Okaeri Configs, multification, JUnit 6, Mockito, Gradle. + +## Global Constraints + +- Material and total amount checks are mandatory. +- Disabled attribute checks neither reject a return nor appear in diagnostics. +- Report all categories with the affected material; include numeric values for amounts and durability. +- Do not expose raw lore or arbitrary NBT in chat. +- Invalid returns give deposited items back and do not withdraw fees, commit, or schedule delivery. +- Do not stage or change the user's `build.gradle.kts` or `PR_REVIEW_feat-parcel-return-gh-69.md`. + +## File Structure + +- `ReturnItemComparator` and `ReturnItemDifference`: low-level diagnostic comparison. +- `ReturnMismatchType`, `ReturnItemMismatch`, and `ParcelReturnValidationResult`: parcel-level diagnostics. +- `ParcelReturnValidator`: exact-first, closest-same-material reconciliation. +- `ReturnMismatchFormatter` and `MessageConfig`: configurable rendering. +- `ParcelReturnService`, `ParcelLockers`, and `ReturnDepositGui`: runtime integration. + +--- + +### Task 1: Diagnostic Item Comparison + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java` + +**Interfaces:** +- Produces `EnumSet differences(ItemStack expected, ItemStack deposited)`. +- Retains predicate-compatible `test(expected, deposited)`. + +- [ ] **Step 1: Write failing diagnostic tests** + +Add exact-set assertions for material, durability, display name, enchantments, lore, residual metadata, and disabled checks: + +```java +assertEquals( + EnumSet.of(ReturnItemDifference.DURABILITY, ReturnItemDifference.ENCHANTMENTS), + equivalence.differences(expected, deposited) +); +``` + +For residual metadata, configure mocked clones so normalized `isSimilar` returns false and assert `EnumSet.of(ReturnItemDifference.NBT)`. Also retain the existing boolean assertions to prove compatibility. + +- [ ] **Step 2: Run the test and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ReturnItemEquivalenceTest" +``` + +Expected: compilation fails because the diagnostic API does not exist. + +- [ ] **Step 3: Add the contract and enum** + +```java +@FunctionalInterface +public interface ReturnItemComparator extends BiPredicate { + EnumSet differences(ItemStack expected, ItemStack deposited); + + @Override + default boolean test(ItemStack expected, ItemStack deposited) { + return this.differences(expected, deposited).isEmpty(); + } +} +``` + +```java +public enum ReturnItemDifference { + MATERIAL, DURABILITY, ITEM_NAME, ENCHANTMENTS, LORE, NBT +} +``` + +- [ ] **Step 4: Implement diagnostic equivalence** + +Make `ReturnItemEquivalence` implement `ReturnItemComparator`. Return `MATERIAL` immediately for different types. Compare each enabled explicit attribute and add its enum value. When `checkNbt` is enabled, clone both items, clear damage, display name, enchantments, and lore on both clones, then compare the normalized clones with `isSimilar`: + +```java +if (this.checks.checkNbt && !this.residualDataMatches(expected, deposited)) { + differences.add(ReturnItemDifference.NBT); +} +``` + +This residual comparison prevents explicit differences from also being labeled NBT. + +- [ ] **Step 5: Run the test and verify GREEN** + +Run the Task 1 test command. Expected: PASS. + +- [ ] **Step 6: Commit Task 1** + +```powershell +git add src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java +git commit -m "refactor: expose return item differences" +``` + +--- + +### Task 2: Structured Parcel Validation + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java` + +**Interfaces:** +- Consumes `ReturnItemComparator.differences(...)`. +- Produces `ParcelReturnValidationResult validate(List deposited, List expected)`. +- Keeps `matches(...)` as a delegate to `validate(...).matches()`. + +- [ ] **Step 1: Write failing structured-result tests** + +Use a material-only comparator: + +```java +private static final ReturnItemComparator BY_MATERIAL = (expected, deposited) -> + expected.getType() == deposited.getType() + ? EnumSet.noneOf(ReturnItemDifference.class) + : EnumSet.of(ReturnItemDifference.MATERIAL); +``` + +Assert exact ordered results for insufficient and excess amount, unexpected material, empty deposit, simultaneous attributes, exact-before-closest pairing, split/merged stacks, aggregation, and deterministic order: + +```java +assertEquals(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.unexpected(Material.DIRT, 2) +), result.mismatches()); +``` + +- [ ] **Step 2: Run the validator test and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ParcelReturnValidatorTest" +``` + +Expected: compilation fails because the result types do not exist. + +- [ ] **Step 3: Add immutable result types** + +```java +public enum ReturnMismatchType { + UNEXPECTED_ITEM, INSUFFICIENT_AMOUNT, EXCESS_AMOUNT, + DURABILITY, ITEM_NAME, ENCHANTMENTS, LORE, NBT +} +``` + +```java +public record ReturnItemMismatch( + ReturnMismatchType type, + Material item, + int expectedAmount, + int depositedAmount, + Integer expectedDamage, + Integer depositedDamage +) { + public static ReturnItemMismatch unexpected(Material item, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.UNEXPECTED_ITEM, item, 0, deposited, null, null); + } + + public static ReturnItemMismatch insufficient(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.INSUFFICIENT_AMOUNT, item, expected, deposited, null, null); + } + + public static ReturnItemMismatch excess(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.EXCESS_AMOUNT, item, expected, deposited, null, null); + } +} +``` + +Add an `attribute(type, expected, deposited)` factory that stores both damage values only for `DURABILITY`, and otherwise stores null damage values. Its private damage helper reads `Damageable#getDamage`, defaulting to zero. + +```java +public record ParcelReturnValidationResult(List mismatches) { + public ParcelReturnValidationResult { + mismatches = List.copyOf(mismatches); + } + + public boolean matches() { + return this.mismatches.isEmpty(); + } +} +``` + +- [ ] **Step 4: Implement deterministic reconciliation** + +Build insertion-ordered expected and deposited totals per material. Emit one amount or unexpected mismatch per material. Wrap stacks in candidates containing the stack and remaining quantity. Consume exact matches first. Then repeatedly select the equal-material pair with the smallest non-zero difference count, breaking ties by expected index and deposited index. Map differences with an exhaustive switch into `ReturnMismatchType`, add records to a `LinkedHashSet`, consume the smaller quantity, and continue. `MATERIAL` is never mapped because closest pairing considers equal materials only. + +- [ ] **Step 5: Run comparison and validator tests and verify GREEN** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ParcelReturnValidatorTest" --tests "com.eternalcode.parcellockers.returns.ReturnItemEquivalenceTest" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 2** + +```powershell +git add src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java +git commit -m "feat: diagnose parcel return mismatches" +``` + +--- + +### Task 3: Configurable Formatting + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java` +- Create: `src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java` + +**Interfaces:** +- Produces `String ReturnMismatchFormatter.format(ParcelReturnValidationResult result)`. + +- [ ] **Step 1: Write failing formatter tests** + +Create real `MessageConfig.ParcelMessages`, replace templates with marker strings, and assert every replacement and ordering: + +```java +messages.returnMismatchSeparator = " | "; +messages.returnMismatchInsufficientAmount = "{ITEM}:{EXPECTED_AMOUNT}/{DEPOSITED_AMOUNT}"; +messages.returnMismatchDurability = "{ITEM}:durability:{EXPECTED_DAMAGE}/{DEPOSITED_DAMAGE}"; +``` + +Expected example: `Diamond:5/4 | Diamond Sword:durability:4/27`. Also assert an empty result throws `IllegalArgumentException`. + +- [ ] **Step 2: Run the formatter test and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ReturnMismatchFormatterTest" +``` + +Expected: compilation fails because the formatter and message fields do not exist. + +- [ ] **Step 3: Add message templates** + +Put `{MISMATCHES}` on a second chat line in `returnItemsMismatch`, and add: + +```java +public String returnMismatchSeparator = ""; +public String returnMismatchUnexpectedItem = "&8- &f{ITEM}: &cunexpected item (deposited {DEPOSITED_AMOUNT})"; +public String returnMismatchInsufficientAmount = "&8- &f{ITEM}: &cinsufficient amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; +public String returnMismatchExcessAmount = "&8- &f{ITEM}: &cexcess amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; +public String returnMismatchDurability = "&8- &f{ITEM}: &cdurability differs (expected damage {EXPECTED_DAMAGE}, deposited {DEPOSITED_DAMAGE})"; +public String returnMismatchItemName = "&8- &f{ITEM}: &ccustom name differs"; +public String returnMismatchEnchantments = "&8- &f{ITEM}: &cenchantments differ"; +public String returnMismatchLore = "&8- &f{ITEM}: &clore differs"; +public String returnMismatchNbt = "&8- &f{ITEM}: &cother item data differs"; +``` + +- [ ] **Step 4: Implement the formatter** + +Hold `MessageConfig.ParcelMessages`, select the template with an exhaustive switch, replace `{ITEM}` through `MaterialUtil.format`, replace relevant numeric markers, and join lines with `returnMismatchSeparator`. Reject matching results because they have no diagnostic text. + +- [ ] **Step 5: Run the formatter test and verify GREEN** + +Run the Task 3 command. Expected: PASS. + +- [ ] **Step 6: Commit Task 3** + +```powershell +git add src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java +git commit -m "feat: format return mismatch reasons" +``` + +--- + +### Task 4: Service and Empty-Deposit Integration + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java` +- Modify: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` +- Create: `src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java` + +**Interfaces:** +- Consumes `ParcelReturnValidator.validate(...)` and `ReturnMismatchFormatter.format(...)`. +- Sends one configured notice with `{MISMATCHES}` replaced. + +- [ ] **Step 1: Write a failing service test** + +Return two mismatches from the validator and `firstsecond` from the formatter. Return a Mockito `NoticeBroadcast` with `RETURNS_SELF` from `noticeService.create()`. Verify: + +```java +verify(broadcast).placeholder("{MISMATCHES}", "firstsecond"); +verify(broadcast).send(); +verify(fixture.returnRepository, never()).commit(any(), any(), any()); +verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); +verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); +``` + +Capture the give-back scheduler callback to retain restoration coverage. + +- [ ] **Step 2: Write a failing empty-confirm GUI test** + +Add a package-private constructor accepting `IntFunction` as a GUI factory. Capture the `GuiAction` passed to `confirmReturnItem.toGuiItem(...)`, execute it with no deposited stacks, and assert: + +```java +verify(guiManager).returnParcel(player, parcel, List.of()); +verify(noticeService, never()).player(any(), any()); +``` + +- [ ] **Step 3: Run focused tests and verify RED** + +```powershell +./gradlew test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnDepositGuiTest" +``` + +Expected: service and GUI still use the old generic paths. + +- [ ] **Step 4: Integrate structured validation** + +Inject `ReturnMismatchFormatter` into `ParcelReturnService`. On invalid validation, give items back and send: + +```java +this.noticeService.create() + .notice(messages -> messages.parcel.returnItemsMismatch) + .player(player.getUniqueId()) + .placeholder("{MISMATCHES}", this.mismatchFormatter.format(validation)) + .send(); +``` + +Return a completed future immediately. Instantiate `new ReturnMismatchFormatter(messageConfig.parcel)` in `ParcelLockers.onEnable()` and pass it into the service. + +- [ ] **Step 5: Allow empty submissions** + +Remove the empty-list early notice from `ReturnDepositGui`. Always mark confirmed, close, and call `guiManager.returnParcel`. Retain the public Triumph GUI factory and use the injected factory only as the test seam. + +- [ ] **Step 6: Run focused tests and verify GREEN** + +Run the Task 4 command. Expected: PASS. + +- [ ] **Step 7: Run full verification** + +```powershell +./gradlew test +``` + +Expected: PASS. Report unavailable Docker separately if it prevents only Testcontainers execution. + +- [ ] **Step 8: Review and commit feature files** + +```powershell +git diff --check +git status --short +git diff -- src/main/java src/test/java +``` + +Stage only files named in Tasks 1-4, then commit: + +```powershell +git commit -m "feat: explain parcel return mismatches" +``` + +Confirm the user's `build.gradle.kts` and `PR_REVIEW_feat-parcel-return-gh-69.md` remain outside every commit. diff --git a/docs/superpowers/plans/2026-07-14-parcel-return-name-format.md b/docs/superpowers/plans/2026-07-14-parcel-return-name-format.md new file mode 100644 index 00000000..ba32665c --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-parcel-return-name-format.md @@ -0,0 +1,147 @@ +# Parcel Return Name Format Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a configurable template that names returned parcels `[Refund] ` by default. + +**Architecture:** Store the template in `PluginConfig.Settings` and apply it when `ParcelReturnService` constructs the reversed `Parcel`. The already-atomic return repository persists the formatted name together with the other return state. + +**Tech Stack:** Java 21, Okaeri Configs, CompletableFuture, JUnit 6, Mockito, Gradle + +## Global Constraints + +- The config field is named `parcelReturnNameFormat` and defaults to `[Refund] {NAME}`. +- Every `{NAME}` occurrence is replaced with the current parcel name. +- A template without `{NAME}` becomes the complete returned-parcel name. +- No GUI, message, notification, fee, timing, or persistence behavior changes. +- Do not commit; the user will create the commit. + +--- + +### Task 1: Configure and apply the returned-parcel name template + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java:97-110` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java:186-203` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: `PluginConfig.Settings`, `Parcel.name()`, and `ParcelReturnRepository.commit(Parcel, ParcelContent, Delivery)`. +- Produces: `PluginConfig.Settings#parcelReturnNameFormat` as a public `String`; the committed returned `Parcel` contains the formatted name. + +- [ ] **Step 1: Write failing behavior tests** + +Add the assertion and captor imports: + +```java +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.mockito.ArgumentCaptor; +``` + +Add these tests to `ParcelReturnServiceTest`: + +```java +@Test +void appliesDefaultPrefixToReturnedParcelName() { + Fixture fixture = new Fixture(); + + assertEquals("[Refund] parcel", fixture.returnedParcelName()); +} + +@Test +void appliesCustomReturnedParcelNameFormat() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned: {NAME} / {NAME}"; + + assertEquals("Returned: parcel / parcel", fixture.returnedParcelName()); +} + +@Test +void usesLiteralReturnedParcelNameWhenFormatHasNoPlaceholder() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned parcel"; + + assertEquals("Returned parcel", fixture.returnedParcelName()); +} +``` + +Add this helper inside `Fixture`: + +```java +private String returnedParcelName() { + this.validReturn(); + when(this.returnRepository.commit(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + this.service.returnParcel(this.player, this.parcel, this.deposited).join(); + + ArgumentCaptor returnedParcel = ArgumentCaptor.forClass(Parcel.class); + verify(this.returnRepository).commit(returnedParcel.capture(), any(), any()); + return returnedParcel.getValue().name(); +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --no-configuration-cache --no-daemon +``` + +Expected: the new tests fail because returned parcels still contain `parcel`; before adding the config field, the two custom-format tests may first fail compilation because `parcelReturnNameFormat` does not exist. Add only the config field declaration if compilation blocks observing the behavioral failures, then rerun and confirm the name assertions fail before changing `ParcelReturnService`. + +- [ ] **Step 3: Add the minimal configuration and formatting implementation** + +Add this field beside the parcel-return window and fees in `PluginConfig.Settings`: + +```java +@Comment({ + "", + "# The name format applied when a parcel is returned.", + "# Placeholder: {NAME} - the original parcel name." +}) +public String parcelReturnNameFormat = "[Refund] {NAME}"; +``` + +In `ParcelReturnService.proceedWithReturn`, replace construction with: + +```java +String returnedName = this.config.settings.parcelReturnNameFormat.replace("{NAME}", current.name()); +Parcel returned = new Parcel(current.uuid(), current.receiver(), returnedName, + current.description(), current.priority(), current.sender(), current.size(), + current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: + +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --no-configuration-cache --no-daemon +``` + +Expected: all `ParcelReturnServiceTest` tests pass, including the three format cases. + +- [ ] **Step 5: Run deterministic regression tests** + +Run: + +```powershell +.\gradlew.bat cleanTest test --tests "com.eternalcode.parcellockers.returns.ReturnItemEquivalenceTest" --tests "com.eternalcode.parcellockers.returns.ParcelReturnValidatorTest" --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" --tests "com.eternalcode.parcellockers.parcel.task.ParcelSendTaskTest" --tests "com.eternalcode.parcellockers.parcel.service.AdminParcelServiceTest" --tests "com.eternalcode.parcellockers.parcel.repository.ParcelPageTest" --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnGuiTest" --tests "com.eternalcode.parcellockers.gui.implementation.admin.AdminParcelEditGuiTest" --tests "com.eternalcode.parcellockers.content.ParcelContentTest" --tests "com.eternalcode.parcellockers.database.ParcelReturnCommitRepositoryIntegrationTest" --no-configuration-cache --no-daemon +``` + +Expected: all deterministic tests pass with zero failures and zero errors. + +- [ ] **Step 6: Build and inspect the patch** + +Run: + +```powershell +.\gradlew.bat shadowJar --no-configuration-cache --no-daemon +git diff --check +git status --short +``` + +Expected: `shadowJar` succeeds, `git diff --check` reports no whitespace errors, and only the intended source, test, spec, and plan files are newly modified by this task. diff --git a/docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md b/docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md new file mode 100644 index 00000000..8c1d74f1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-parcel-return-review-fixes.md @@ -0,0 +1,292 @@ +# Parcel Return Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make parcel returns concurrency-safe, durably scheduled, locker-valid, and main-thread-safe. + +**Architecture:** A return-specific ORMLite repository commits the conditional parcel transition, deposited content, delivery row, and collected-row cleanup in one transaction. The service validates locker existence and captures Bukkit permission state before async work, invalidates affected caches after commit, and schedules only after persistence. Return GUI data remains asynchronous while all GUI mutation is dispatched through the primary-thread scheduler. + +**Tech Stack:** Java 21, Paper 1.21, ORMLite 6.1, CompletableFuture, Triumph GUI, JUnit 5, Mockito, Testcontainers MySQL. + +## Global Constraints + +- Preserve the existing parcel-return behavior and player-facing configuration. +- Add no dependencies or schema columns. +- Repository work remains asynchronous through the existing `Scheduler`. +- Bukkit player, inventory, event, and GUI APIs run on the primary thread. +- Use a failing regression test before each production behavior change. +- Docker-gated integration tests may skip only when Docker is unavailable; report that explicitly. +- Do not commit or modify unrelated user-owned files. + +--- + +### Task 1: Atomic Return Commit + +**Files:** +- Create: `src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java` +- Create: `src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java` +- Test: `src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java` + +**Interfaces:** +- Consumes: `Parcel`, `ParcelContent`, `Delivery`, and `DatabaseManager.connectionSource()`. +- Produces: `CompletableFuture ParcelReturnRepository.commit(Parcel, ParcelContent, Delivery)`. + +- [ ] **Step 1: Write the failing concurrent-winner integration test** + +Create two return commits for the same `COLLECTED` parcel with different material content and delivery timestamps: + +```java +CompletableFuture first = repository.commit(returned, + new ParcelContent(parcel.uuid(), List.of(new ItemStack(Material.DIAMOND))), + new Delivery(parcel.uuid(), firstDelivery)); +CompletableFuture second = repository.commit(returned, + new ParcelContent(parcel.uuid(), List.of(new ItemStack(Material.EMERALD))), + new Delivery(parcel.uuid(), secondDelivery)); + +boolean firstWon = await(this.first); +boolean secondWon = await(this.second); +assertNotEquals(firstWon, secondWon); +Material expectedMaterial = this.firstWon ? Material.DIAMOND : Material.EMERALD; +Instant expectedDelivery = this.firstWon ? firstDelivery : secondDelivery; +assertEquals(expectedMaterial, await(contentRepository.find(parcel.uuid())) + .orElseThrow().items().getFirst().getType()); +assertEquals(expectedDelivery, await(deliveryRepository.find(parcel.uuid())) + .orElseThrow().deliveryTimestamp()); +assertEquals(ParcelStatus.SENT, await(parcelRepository.findById(parcel.uuid())) + .orElseThrow().status()); +assertTrue(await(collectedRepository.find(parcel.uuid())).isEmpty()); +``` + +- [ ] **Step 2: Run the new test to verify RED** + +Run: +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.database.ParcelReturnCommitRepositoryIntegrationTest" +``` + +Expected: compilation fails because `ParcelReturnRepository` does not exist. + +- [ ] **Step 3: Implement the transaction** + +Define: + +```java +public interface ParcelReturnRepository { + CompletableFuture commit(Parcel returned, ParcelContent content, Delivery delivery); +} +``` + +The ORMLite implementation runs on `action(ParcelTable.class, ...)` and calls: + +```java +return TransactionManager.callInTransaction(this.databaseManager.connectionSource(), () -> { + UpdateBuilder update = parcelDao.updateBuilder(); + update.updateColumnValue("sender", returned.sender()); + update.updateColumnValue("receiver", returned.receiver()); + update.updateColumnValue("entry_locker", returned.entryLocker()); + update.updateColumnValue("destination_locker", returned.destinationLocker()); + update.updateColumnValue("status", ParcelStatus.SENT); + update.where().eq("uuid", returned.uuid()).and().eq("status", ParcelStatus.COLLECTED); + if (update.update() == 0) { + return false; + } + this.databaseManager.getDao(ParcelContentTable.class) + .createOrUpdate(ParcelContentTable.from(content)); + this.databaseManager.getDao(DeliveryTable.class) + .createOrUpdate(DeliveryTable.from(delivery)); + this.databaseManager.getDao(CollectedParcelTable.class).deleteById(returned.uuid()); + return true; +}); +``` + +Expose only the table classes and `from(...)` factories required by the coordinator; keep constructors and domain conversion methods otherwise unchanged. + +- [ ] **Step 4: Run the integration test to verify GREEN** + +Run the command from Step 2. Expected: both atomicity tests pass when Docker is available; otherwise JUnit reports the Docker-gated class skipped. + +--- + +### Task 2: Use the Atomic Commit and Preserve Cache Correctness + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java` +- Modify: `src/main/java/com/eternalcode/parcellockers/ParcelLockers.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: `ParcelReturnRepository.commit(...)`. +- Produces: `void invalidate(UUID)` on parcel/content/delivery caches. + +- [ ] **Step 1: Write a failing service test for durable commit ordering** + +Mock `ParcelReturnRepository.commit` with an incomplete future, call `returnParcel`, and verify that neither `scheduler.runLaterAsync` nor the returned-success notice occurs until that future completes `true`. Complete it `false` in a second test and verify the deposited items are returned and no task is scheduled. + +- [ ] **Step 2: Run the focused service test to verify RED** + +Run: +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.ParcelReturnServiceTest" +``` + +Expected: compilation fails because the service has no return repository dependency or cache invalidation API. + +- [ ] **Step 3: Implement commit-first scheduling** + +Prepare one delivery timestamp, then: + +```java +ParcelContent content = new ParcelContent(returned.uuid(), depositedCopy); +Delivery delivery = new Delivery(returned.uuid(), Instant.now().plus(delay)); +return this.parcelReturnRepository.commit(returned, content, delivery).thenCompose(committed -> { + if (!committed) { + this.refund(player, refundableFee); + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + this.parcelService.invalidate(returned.uuid()); + this.parcelContentManager.invalidate(returned.uuid()); + this.deliveryManager.invalidate(returned.uuid()); + this.scheduler.runLaterAsync( + new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), delay); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + return CompletableFuture.completedFuture(null); +}); +``` + +Remove the pre-claim content update, separate `markReturned`, fire-and-forget delivery update, and best-effort collected-row delete from this flow. Wire `ParcelReturnRepositoryOrmLite` in `ParcelLockers.onEnable`. + +- [ ] **Step 4: Run the focused service test to verify GREEN** + +Run the command from Step 2. Expected: all service tests pass. + +--- + +### Task 3: Validate Locker Existence and Capture Permission on Main Thread + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java` +- Test: `src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java` + +**Interfaces:** +- Consumes: existing `LockerManager.get(UUID)` and `LockerManager.isLockerFull(UUID)`. +- Produces: no new public API. + +- [ ] **Step 1: Write failing missing-locker and permission-timing tests** + +For the missing-locker test, return `Optional.empty()` from `lockerManager.get(entryLocker)`, then verify `isLockerFull`, economy withdrawal, and repository commit are never called. For permission timing, leave `parcelService.get` incomplete and verify `player.hasPermission("parcellockers.fee.bypass")` was already called immediately after `returnParcel`. + +- [ ] **Step 2: Run the focused service test to verify RED** + +Run the Task 2 focused test command. Expected: missing-locker verification fails because `get` is not called, and permission verification fails because it is currently delayed until an async continuation. + +- [ ] **Step 3: Implement the main-thread and existence checks** + +Capture: + +```java +boolean feeBypassed = player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION); +``` + +before the first repository future, pass it into `execute`, and replace the direct fullness call with: + +```java +return this.lockerManager.get(current.entryLocker()).thenCompose(locker -> { + if (locker.isEmpty()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { + if (Boolean.TRUE.equals(isFull)) { + return this.abort(player, deposited, messages -> messages.parcel.lockerFull); + } + return this.execute(player, current, deposited, feeBypassed); + }); +}); +``` + +- [ ] **Step 4: Run the focused service test to verify GREEN** + +Run the Task 2 focused test command. Expected: all service tests pass. + +--- + +### Task 4: Confine Return GUI Mutation to the Primary Thread + +**Files:** +- Modify: `src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java` +- Create: `src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java` + +**Interfaces:** +- Consumes: `Scheduler.run(Runnable)`. +- Produces: a package-private constructor accepting `Function` for test-controlled GUI creation. + +- [ ] **Step 1: Write the failing empty-result threading test** + +Construct `ReturnGui` with a mocked `PaginatedGui), an incomplete returnable-parcels future, and a mocked scheduler that captures but does not run callbacks. After `show`, clear initial synchronous setup interactions, complete the future with an empty page, verify no new GUI mutation occurred, run the captured callback, then verify the empty item was set and the GUI opened. + +- [ ] **Step 2: Run the GUI test to verify RED** + +Run: +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnGuiTest" +``` + +Expected: the verification fails because the current async continuation calls `gui.setItem` before scheduling. + +- [ ] **Step 3: Implement a single scheduled render boundary** + +Keep data futures asynchronous. Move empty-state item placement, navigation setup, `PaginatedGuiRefresher` construction/population, and `gui.open` into `scheduler.run(() -> { ... })`. The public constructor delegates to the package-private factory constructor: + +```java +this(guiSettings, scheduler, guiManager, miniMessage, noticeService, + title -> Gui.paginated().rows(6).disableAllInteractions().title(title).create()); +``` + +- [ ] **Step 4: Run the GUI test to verify GREEN** + +Run the command from Step 2. Expected: the test passes. + +--- + +### Task 5: Full Verification + +**Files:** +- Review all files changed by Tasks 1-4. + +**Interfaces:** +- Consumes: completed fixes. +- Produces: fresh test/build evidence and a clean patch check. + +- [ ] **Step 1: Run focused return and GUI tests** + +```powershell +.\gradlew.bat test --tests "com.eternalcode.parcellockers.returns.*" --tests "com.eternalcode.parcellockers.gui.implementation.locker.ReturnGuiTest" --tests "com.eternalcode.parcellockers.database.ParcelReturnCommitRepositoryIntegrationTest" +``` + +- [ ] **Step 2: Run the full suite** + +```powershell +.\gradlew.bat test +``` + +- [ ] **Step 3: Build the plugin JAR** + +```powershell +.\gradlew.bat shadowJar +``` + +- [ ] **Step 4: Inspect the final patch** + +```powershell +git diff --check +git status --short +``` + +Expected: commands exit successfully; report any Docker-skipped Testcontainers tests rather than describing them as executed. diff --git a/docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md b/docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md new file mode 100644 index 00000000..82d50032 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-parcel-return-mismatch-details-design.md @@ -0,0 +1,89 @@ +# Detailed Parcel Return Mismatch Messages + +## Goal + +When a player deposits items to return a collected parcel and those items do not match the original contents, report every mismatch category and identify the affected item. Preserve the existing safety behavior: reject the return, give all deposited items back, and perform no fee withdrawal, database commit, or delivery scheduling. + +## Validation Result + +Replace the validator's boolean-only result with a structured result that contains whether the deposit matches and an ordered list of item-specific mismatches. Keep a boolean convenience method only where it avoids unnecessary caller churn; the structured result is the source of truth used by `ParcelReturnService`. + +Mismatch categories are: + +- unexpected material; +- insufficient amount; +- excess amount; +- durability; +- custom item name; +- enchantments; +- lore; +- other NBT/item metadata. + +Checks disabled in `PluginConfig.ReturnChecks` do not reject a return and do not appear in the mismatch list. Material and total amount remain mandatory checks. + +`ReturnItemEquivalence` exposes an attribute comparison in addition to its predicate-compatible boolean result. The attribute comparison isolates explicit attributes from residual metadata so, for example, an enchantment difference is reported as `enchantments`, not redundantly as both `enchantments` and `other item metadata`. The existing equivalence semantics remain unchanged. + +## Matching Algorithm + +Validation works on remaining quantities rather than physical stack boundaries, preserving the current behavior where split and merged stacks are equivalent. + +1. Consume deposited quantities that exactly match an expected equivalence group. +2. For remaining quantities, pair stacks of the same material using the smallest number of enabled attribute differences. Input order breaks ties to keep the result deterministic. +3. Report every attribute category found for each paired item and consume the paired quantity. +4. Report remaining expected quantities as insufficient amounts. +5. Report remaining deposited quantities as excess amounts when that material was expected, or as unexpected items when it was not expected. +6. Aggregate duplicate findings for the same item and category so the notice stays concise. + +Pairing same-material items after exact matches prevents an altered sword from being presented as both a missing original sword and an unrelated extra sword. If multiple variants of one material exist, closest-pair matching produces the most useful explanation without changing the acceptance rules. + +## Message Configuration + +Keep `MessageConfig.ParcelMessages.returnItemsMismatch` as the single failure notice and add a `{MISMATCHES}` placeholder. Add configurable templates for each reason and for joining multiple reasons, ensuring all player-facing wording remains in `MessageConfig`. + +Default output lists each aggregated reason on a separate line. Examples: + +```text +Diamond Sword: insufficient amount (expected 2, deposited 1) +Diamond Sword: enchantments differ +Diamond Sword: durability differs (expected damage 4, deposited 27) +Dirt: unexpected item (deposited 16) +``` + +Amounts and durability include expected and deposited numeric values. Material mismatches include readable material names. Custom names, enchantments, lore, and arbitrary metadata identify the item and mismatch category; potentially long or unsafe raw metadata is not dumped into chat. + +The empty-deposit shortcut in `ReturnDepositGui` is removed. Confirming an empty deposit invokes the normal return service, allowing the validator to report each missing expected item through the same configured notice. + +## Service Flow and Failure Handling + +After loading the current parcel content, `ParcelReturnService` asks the validator for a structured result. A valid result continues through the existing locker, fee, and atomic-commit flow. An invalid result formats every mismatch with `MessageConfig`, supplies the combined value as `{MISMATCHES}`, gives the deposited items back, and completes without further return processing. + +All other abort paths retain their current notices. Diagnostic formatting is side-effect-free and contains no shared mutable state, so concurrent asynchronous returns cannot leak mismatch details between players. + +## Testing + +Implementation follows test-driven development. Focused tests are added and observed failing before production changes. + +`ReturnItemEquivalenceTest` covers: + +- each explicit mismatch category; +- disabled checks; +- residual NBT detection without duplicate explicit categories; +- unchanged boolean equivalence behavior. + +`ParcelReturnValidatorTest` covers: + +- exact, split, and merged matches; +- insufficient and excess quantities with expected/deposited values; +- unexpected materials; +- several simultaneous categories; +- closest same-material pairing; +- empty deposits; +- aggregation and deterministic ordering. + +Service and GUI-level tests cover: + +- one configured notice containing all formatted item-specific reasons; +- deposited-item restoration and absence of fee, commit, and scheduling on mismatch; +- empty confirmation reaching normal validation rather than sending the old generic GUI notice. + +After focused tests pass, run the complete unit-test suite. Existing user changes in `build.gradle.kts` and `PR_REVIEW_feat-parcel-return-gh-69.md` are outside this work and remain untouched. diff --git a/docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md b/docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md new file mode 100644 index 00000000..eb060775 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-parcel-return-name-format-design.md @@ -0,0 +1,41 @@ +# Configurable Parcel Return Name Format + +## Goal + +Allow server administrators to configure the name assigned to a returned parcel. Returned parcels should use a configurable template instead of preserving the original name unchanged. + +## Configuration + +Add `parcelReturnNameFormat` to `PluginConfig.Settings`, next to the existing parcel-return settings. + +Default value: + +```text +[Refund] {NAME} +``` + +Okaeri Configs will expose this field in the generated YAML as `parcelReturnNameFormat`, matching the existing camelCase configuration keys. + +`{NAME}` represents the parcel name immediately before the return is committed. Every occurrence of the placeholder is replaced. If the configured value contains no `{NAME}` placeholder, the configured value becomes the complete returned-parcel name. + +## Return Flow + +When `ParcelReturnService` constructs the returned `Parcel`, it formats the name using `config.settings.parcelReturnNameFormat` and the current parcel name. The formatted name is included in the existing atomic return transaction, so no additional persistence operation is introduced. + +No other parcel fields, GUI labels, messages, or notification behavior change. + +## Compatibility + +Existing installations receive the new default during config migration/generation. Therefore, after updating, returned parcels are named `[Refund] ` unless the administrator changes the format to `{NAME}` or another template. + +Formatting codes are stored as part of the parcel name exactly as configured; rendering remains the responsibility of the existing GUI/message pipeline. + +## Testing + +Extend `ParcelReturnServiceTest` using the existing mocked atomic repository boundary: + +- verify the default format produces `[Refund] ` in the committed parcel; +- verify a custom format replaces `{NAME}` with the original name; +- verify a format without `{NAME}` becomes the complete returned-parcel name. + +Implementation follows test-driven development: add and run a failing behavior test before changing production code, then implement the smallest formatting change and rerun the focused and deterministic suites. diff --git a/docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md b/docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md new file mode 100644 index 00000000..abd60b72 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-parcel-return-review-fixes-design.md @@ -0,0 +1,101 @@ +# Parcel Return Review Fixes — Design + +Date: 2026-07-14 +Status: Approved in conversation +Review: `PR_REVIEW_feat-parcel-return-gh-69.md` + +## Summary + +The parcel-return flow needs four corrections before merge: atomic winner-owned +content, durable delivery scheduling, destination-locker validation, and safe +main-thread boundaries for Bukkit and GUI APIs. + +## Accepted findings + +All four findings are valid for the current branch: + +1. Content replacement happens before the conditional return claim, so a losing + concurrent attempt can overwrite the winning shipment's content. +2. Locker capacity lookup does not prove that the original entry locker still + exists, so a returned parcel can be addressed to a deleted locker. +3. The parcel becomes `SENT` before its delivery row is durably persisted, so a + restart can leave it without anything startup recovery can schedule. +4. Repository futures complete on async scheduler threads, but `ReturnGui` mutates + GUI state and `ParcelReturnService` checks player permissions in continuations. + +## Architecture + +### Atomic return commit + +Introduce a return-specific repository operation backed by ORMLite's transaction +support. It accepts the prepared reverse `Parcel`, deposited `ParcelContent`, and +`Delivery`, then performs these operations on one database connection and in one +transaction: + +1. Conditionally update the parcel only when its current status is `COLLECTED`. +2. If no row was updated, return `false` without changing content or delivery data. +3. Upsert the deposited parcel content. +4. Upsert the delivery timestamp. +5. Delete the corresponding `collected_parcels` row. +6. Commit and return `true`. + +Any exception rolls back every operation. Two concurrent attempts therefore have +one winner, and the content and delivery row always belong to that winner. The +transaction also makes every committed `SENT` return discoverable by startup +delivery recovery before the player is told it succeeded. + +After a successful commit, the service synchronizes the parcel, content, and +delivery caches without issuing additional database writes. It then schedules the +in-memory delivery task. If scheduling throws during shutdown, the durable delivery +row remains available for startup recovery; deposited items and fees are not +returned because the reverse shipment has already committed. + +### Locker validation + +Resolve the original entry locker with `LockerManager.get` before the fullness +query. An absent locker rejects the attempt using the generic cannot-return notice, +returns the deposited items, and does not charge a fee. Capacity is checked only for +an existing locker. + +### Main-thread boundary + +Capture the fee-bypass permission before entering repository continuations. The +return entry point is invoked by a GUI event on the primary thread, while Vault +withdrawal and refund operations remain explicitly scheduled there. + +`ReturnGui` continues fetching parcel/content data asynchronously, but every GUI +mutation is placed in one primary-thread callback: navigation setup, empty-state +item placement, `PaginatedGuiRefresher` population, and `gui.open`. Async work may +only prepare immutable/plain data and deferred item suppliers. + +## Error handling + +- A conditional-claim loss refunds any charged fee and gives the deposited items + back. +- A transaction failure rolls back all persistent return state, refunds the fee, + gives the deposited items back, and sends the generic failure notice. +- A post-commit scheduling failure is logged but never triggers refund/item + give-back, because doing so would duplicate committed shipment contents. +- A missing original locker is handled before charging or committing. + +## Testing + +- Add a Docker-gated integration test that launches two return commits with + different deposited content, asserts exactly one succeeds, and verifies parcel, + content, and delivery rows all belong to the winner. +- Add an integration test proving a failed conditional claim leaves existing + content and delivery data unchanged. +- Add focused service tests for missing original locker and for permission capture + before async repository completion where practical with existing mocks. +- Add a focused `ReturnGui` test or extract a small scheduling boundary that proves + GUI mutation is dispatched through the primary-thread scheduler. +- Run focused tests after each red/green cycle, then the complete Gradle test suite + and `shadowJar`. + +## Out of scope + +- Changing return fees, windows, item-equivalence rules, or GUI copy. +- Introducing an intermediate `RETURNING` parcel status. +- Solving cross-request locker-capacity reservation beyond the existing best-effort + fullness model. +- Refactoring unrelated send/collection persistence paths. diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index c7342143..12ef2ba9 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -44,6 +44,13 @@ import com.eternalcode.parcellockers.parcel.service.ParcelService; import com.eternalcode.parcellockers.parcel.service.ParcelServiceImpl; import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; +import com.eternalcode.parcellockers.returns.ParcelReturnService; +import com.eternalcode.parcellockers.returns.ParcelReturnValidator; +import com.eternalcode.parcellockers.returns.ReturnItemEquivalence; +import com.eternalcode.parcellockers.returns.ReturnMismatchFormatter; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.task.ReturnWindowPurgeTask; import com.eternalcode.parcellockers.updater.UpdaterService; import com.eternalcode.parcellockers.user.UserManager; import com.eternalcode.parcellockers.user.UserManagerImpl; @@ -122,12 +129,16 @@ public void onEnable() { DeliveryRepositoryOrmLite deliveryRepository = new DeliveryRepositoryOrmLite(databaseManager, scheduler); ItemStorageRepository itemStorageRepository = new ItemStorageRepositoryOrmLite(databaseManager, scheduler); UserRepository userRepository = new UserRepositoryOrmLite(databaseManager, scheduler); + CollectedParcelRepositoryOrmLite collectedParcelRepository = new CollectedParcelRepositoryOrmLite(databaseManager, scheduler); + ParcelReturnRepositoryOrmLite parcelReturnRepository = + new ParcelReturnRepositoryOrmLite(databaseManager, scheduler); // service and managers ParcelService parcelService = new ParcelServiceImpl( noticeService, parcelRepository, parcelContentRepository, + collectedParcelRepository, scheduler, config, this.economy, @@ -152,6 +163,30 @@ public void onEnable() { noticeService ); + ParcelReturnValidator returnValidator = new ParcelReturnValidator(new ReturnItemEquivalence(config.settings.returnChecks)); + ReturnMismatchFormatter returnMismatchFormatter = new ReturnMismatchFormatter(messageConfig.parcel); + ParcelReturnService parcelReturnService = new ParcelReturnService( + parcelService, + parcelContentManager, + collectedParcelRepository, + deliveryManager, + lockerManager, + returnValidator, + returnMismatchFormatter, + parcelReturnRepository, + scheduler, + config, + noticeService, + this.economy, + server + ); + + scheduler.timerAsync( + new ReturnWindowPurgeTask(parcelService, collectedParcelRepository, deliveryManager, config), + Duration.ofSeconds(30), + Duration.ofMinutes(30) + ); + // guis TriumphGui.init(this); GuiManager guiManager = new GuiManager( @@ -162,7 +197,9 @@ public void onEnable() { parcelDispatchService, parcelContentManager, deliveryManager, - config.settings.allowCollectingFromAnyLocker + config.settings.allowCollectingFromAnyLocker, + parcelReturnService, + config.settings.parcelReturnWindow ); MainGui mainGUI = new MainGui( diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index a7f00713..a06d6d81 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -118,13 +118,42 @@ public static class ParcelMessages extends OkaeriConfig { .sound(SoundEventKeys.ENTITY_ITEM_BREAK) .build(); public Notice insufficientFunds = Notice.builder() - .chat("&4✘ &cYou do not have enough funds to send this parcel! Required: &6${AMOUNT}&c.") + .chat("&4✘ &cYou do not have enough funds to cover this fee! Required: &6${AMOUNT}&c.") .sound(SoundEventKeys.ENTITY_VILLAGER_NO) .build(); public Notice feeWithdrawn = Notice.builder() .chat("&2✔ &a${AMOUNT} has been withdrawn from your account to cover the parcel sending fee.") .sound(SoundEventKeys.ENTITY_EXPERIENCE_ORB_PICKUP) .build(); + public Notice returned = Notice.builder() + .chat("&2✔ &aParcel returned. It is on its way back to the sender.") + .sound(SoundEventKeys.ENTITY_PLAYER_LEVELUP) + .build(); + public Notice cannotReturn = Notice.builder() + .chat("&4✘ &cThis parcel cannot be returned right now. Your items were given back.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice returnItemsMismatch = Notice.builder() + .chat("&4✘ &cThe deposited items do not match the original parcel contents!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public String returnMismatchSeparator = ""; + public String returnMismatchUnexpectedItem = "&8- &f{ITEM}: &cunexpected item (deposited {DEPOSITED_AMOUNT})"; + public String returnMismatchInsufficientAmount = "&8- &f{ITEM}: &cinsufficient amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; + public String returnMismatchExcessAmount = "&8- &f{ITEM}: &cexcess amount (expected {EXPECTED_AMOUNT}, deposited {DEPOSITED_AMOUNT})"; + public String returnMismatchDurability = "&8- &f{ITEM}: &cdurability differs (expected damage {EXPECTED_DAMAGE}, deposited {DEPOSITED_DAMAGE})"; + public String returnMismatchItemName = "&8- &f{ITEM}: &ccustom name differs"; + public String returnMismatchEnchantments = "&8- &f{ITEM}: &cenchantments differ"; + public String returnMismatchLore = "&8- &f{ITEM}: &clore differs"; + public String returnMismatchNbt = "&8- &f{ITEM}: &cother item data differs"; + public Notice returnWindowExpired = Notice.builder() + .chat("&4✘ &cThe return window for this parcel has expired!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice returnFeeWithdrawn = Notice.builder() + .chat("&2✔ &a${AMOUNT} has been withdrawn from your account to cover the parcel return fee.") + .sound(SoundEventKeys.ENTITY_EXPERIENCE_ORB_PICKUP) + .build(); @Comment({"", "# The parcel info message." }) public Notice parcelInfoMessages = Notice.builder() @@ -189,6 +218,7 @@ public static class AdminMessages extends OkaeriConfig { public Notice teleportWorldMissing = Notice.chat("&4✘ &cThat locker's world is not loaded."); public Notice sizeTooSmall = Notice.chat("&4✘ &cThe parcel's contents do not fit in that size."); public Notice destinationFull = Notice.chat("&4✘ &cThat destination locker is full."); + public Notice statusLocked = Notice.chat("&4✘ &cA collected parcel's status cannot be changed; it can only be returned."); public Notice contentsUpdated = Notice.chat("&2✔ &aParcel contents updated."); public Notice priorityUpdated = Notice.chat("&2✔ &aPriority updated and delivery time adjusted."); public Notice noPermission = Notice.chat("&4✘ &cYou do not have permission to do that."); diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java index 1ae9d486..05ead475 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -94,6 +94,32 @@ public static class Settings extends OkaeriConfig { @Comment({"", "# Large parcel fee in in-game currency"}) public double largeParcelFee = 50.0; + + @Comment({"", "# How long after collection a parcel can still be returned.", "# Expired collected parcels are purged periodically."}) + public Duration parcelReturnWindow = Duration.ofDays(7); + + @Comment({ + "", + "# The name format applied when a parcel is returned.", + "# Placeholder: {NAME} - the original parcel name." + }) + public String parcelReturnNameFormat = "[Refund] {NAME}"; + + @Comment({"", "# Small parcel return fee in in-game currency"}) + public double smallParcelReturnFee = 5.0; + + @Comment({"", "# Medium parcel return fee in in-game currency"}) + public double mediumParcelReturnFee = 12.5; + + @Comment({"", "# Large parcel return fee in in-game currency"}) + public double largeParcelReturnFee = 25.0; + + @Comment({ + "", + "# Which item attributes must match the original parcel content when a player returns a parcel.", + "# Material types and total amounts must always match; each flag below relaxes one attribute when set to false." + }) + public ReturnChecks returnChecks = new ReturnChecks(); } public static class GuiSettings extends OkaeriConfig { @@ -490,6 +516,66 @@ public static class GuiSettings extends OkaeriConfig { @Comment({ "", "# The lore line showing when the parcel has arrived. Placeholders: {DATE} - arrival date" }) public String parcelArrivedLine = "&aArrived on: &2{DATE}"; + + @Comment({ "", "# The title of the parcel return GUI" }) + public String parcelReturnGuiTitle = "&5Return parcels"; + + @Comment({ "", "# The title of the return deposit GUI" }) + public String parcelReturnDepositGuiTitle = "&5Deposit the parcel items"; + + @Comment({ "", "# The item of the parcel locker return button" }) + public ConfigItem parcelLockerReturnItem = new ConfigItem() + .name("&5↩ Return parcels") + .lore(List.of("&5» &dClick to return a collected parcel.")) + .type(Material.HOPPER) + .glow(true); + + @Comment({ "", "# The item of the parcel in the return GUI" }) + public ConfigItem parcelReturnRowItem = new ConfigItem() + .name("&d{NAME}") + .lore(List.of( + "&6Sender: &e{SENDER}", + "&6Size: &e{SIZE}", + "&6Description: &e{DESCRIPTION}" + ) + ) + .type(Material.CHEST_MINECART); + + @Comment({ "", "# The item displayed in the return GUI when there is nothing to return" }) + public ConfigItem noReturnableParcelsItem = new ConfigItem() + .name("&4✘ &cNo returnable parcels") + .lore(List.of("&cYou don't have any parcels to return.")) + .type(Material.STRUCTURE_VOID); + + @Comment({ "", "# The item of the confirm return button" }) + public ConfigItem confirmReturnItem = new ConfigItem() + .name("&2✔ &aConfirm return") + .lore(List.of("&2» &aDeposit the original items above, then click to return the parcel.")) + .type(Material.GREEN_DYE); + + @Comment({ "", "# The lore line showing how long the parcel can still be returned. Placeholder: {DURATION}" }) + public String returnWindowRemainingLine = "&5Return window: &d{DURATION} left"; + + @Comment({ "", "# The lore line shown when the return window has expired." }) + public String returnWindowExpiredLine = "&cReturn window expired"; + } + + public static class ReturnChecks extends OkaeriConfig { + + @Comment("# Whether durability (damage) must match the original items.") + public boolean checkDurability = true; + + @Comment("# Whether custom display names must match the original items.") + public boolean checkItemName = true; + + @Comment("# Whether enchantments must match the original items.") + public boolean checkEnchantments = true; + + @Comment("# Whether lore must match the original items.") + public boolean checkLore = true; + + @Comment({"# Whether all remaining item data (NBT) must match the original items.", "# When false, only the attributes enabled above are compared."}) + public boolean checkNbt = true; } public static class DiscordSettings extends OkaeriConfig { diff --git a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java b/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java index 69cdc053..4f477bd9 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java +++ b/src/main/java/com/eternalcode/parcellockers/content/ParcelContentManager.java @@ -66,6 +66,10 @@ public CompletableFuture delete(UUID parcel) { }); } + public void invalidate(UUID parcel) { + this.cache.invalidate(parcel); + } + public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { return this.contentRepository.deleteAll().thenAccept(deleted -> { noticeService.create() diff --git a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java index e99f0aa2..4ae90920 100644 --- a/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java +++ b/src/main/java/com/eternalcode/parcellockers/content/repository/ParcelContentTable.java @@ -9,7 +9,7 @@ import org.bukkit.inventory.ItemStack; @DatabaseTable(tableName = "parcel_content") -class ParcelContentTable { +public class ParcelContentTable { @DatabaseField(id = true) private UUID uniqueId; @@ -25,7 +25,7 @@ class ParcelContentTable { this.content = content; } - static ParcelContentTable from(ParcelContent parcelContent) { + public static ParcelContentTable from(ParcelContent parcelContent) { return new ParcelContentTable(parcelContent.uniqueId(), parcelContent.items()); } diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java b/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java index d34ae7d7..265136f5 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/DeliveryManager.java @@ -64,6 +64,10 @@ public CompletableFuture delete(UUID parcel) { }); } + public void invalidate(UUID parcel) { + this.deliveryCache.invalidate(parcel); + } + public CompletableFuture deleteAll(CommandSender sender, NoticeService noticeService) { return this.deliveryRepository.deleteAll().thenAccept(deleted -> { this.deliveryCache.invalidateAll(); diff --git a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java index 6fc570bc..42fa18f2 100644 --- a/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java +++ b/src/main/java/com/eternalcode/parcellockers/delivery/repository/DeliveryTable.java @@ -8,7 +8,7 @@ import java.util.UUID; @DatabaseTable(tableName = "deliveries") -class DeliveryTable { +public class DeliveryTable { @DatabaseField(id = true) private UUID parcel; diff --git a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java index 5ed683ef..54666dc9 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/GuiManager.java @@ -12,10 +12,13 @@ import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.service.ParcelDispatchService; import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.ParcelReturnService; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.user.User; import com.eternalcode.parcellockers.user.UserManager; +import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -35,6 +38,8 @@ public class GuiManager { private final ParcelContentManager parcelContentManager; private final DeliveryManager deliveryManager; private final boolean allowCollectingFromAnyLocker; + private final ParcelReturnService parcelReturnService; + private final Duration returnWindow; public GuiManager( ParcelService parcelService, @@ -44,7 +49,9 @@ public GuiManager( ParcelDispatchService parcelDispatchService, ParcelContentManager parcelContentManager, DeliveryManager deliveryManager, - boolean allowCollectingFromAnyLocker + boolean allowCollectingFromAnyLocker, + ParcelReturnService parcelReturnService, + Duration returnWindow ) { this.parcelService = parcelService; this.lockerManager = lockerManager; @@ -54,6 +61,13 @@ public GuiManager( this.parcelContentManager = parcelContentManager; this.deliveryManager = deliveryManager; this.allowCollectingFromAnyLocker = allowCollectingFromAnyLocker; + this.parcelReturnService = parcelReturnService; + this.returnWindow = returnWindow; + } + + /** The configured return window, exposed so GUIs don't each need their own copy threaded in. */ + public Duration returnWindow() { + return this.returnWindow; } /** @@ -157,4 +171,16 @@ public CompletableFuture deleteAllParcels(CommandSender sender, NoticeServ public CompletableFuture deleteAllLockers(CommandSender sender, NoticeService noticeService) { return this.lockerManager.deleteAll(sender, noticeService); } + + public CompletableFuture> getReturnableParcels(UUID receiver, Page page) { + return this.parcelService.getReturnable(receiver, page); + } + + public CompletableFuture> getCollectedInfo(UUID parcelId) { + return this.parcelReturnService.getCollectedInfo(parcelId); + } + + public void returnParcel(Player player, Parcel parcel, List deposited) { + this.parcelReturnService.returnParcel(player, parcel, deposited); + } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java index 60fa2f47..38fe917a 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/admin/AdminParcelEditGui.java @@ -188,6 +188,7 @@ private void notifyResult(Player player, EditResult result) { case OK -> this.noticeService.create().notice(m -> m.admin.parcelUpdated).player(player.getUniqueId()).send(); case SIZE_TOO_SMALL -> this.noticeService.create().notice(m -> m.admin.sizeTooSmall).player(player.getUniqueId()).send(); case DESTINATION_FULL -> this.noticeService.create().notice(m -> m.admin.destinationFull).player(player.getUniqueId()).send(); + case PARCEL_COLLECTED -> this.noticeService.create().notice(m -> m.admin.statusLocked).player(player.getUniqueId()).send(); } } @@ -227,6 +228,11 @@ private static ParcelSize nextSize(ParcelSize size) { } private static ParcelStatus nextStatus(ParcelStatus status) { + if (status == ParcelStatus.COLLECTED) { + // A COLLECTED parcel cannot be toggled back to SENT/DELIVERED; AdminParcelService + // rejects the change too, so this only avoids offering the flip in the first place. + return ParcelStatus.COLLECTED; + } return status == ParcelStatus.SENT ? ParcelStatus.DELIVERED : ParcelStatus.SENT; } } diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java index 3ee62762..d517651d 100644 --- a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/LockerGui.java @@ -53,7 +53,15 @@ public void show(Player player, UUID entryLocker) { entryLocker ); + ReturnGui returnGui = new ReturnGui( + this.guiSettings, + this.scheduler, + this.guiManager, + this.miniMessage + ); + gui.setItem(21, this.guiSettings.parcelLockerCollectItem.toGuiItem(event -> collectionGui.show(player))); + gui.setItem(22, this.guiSettings.parcelLockerReturnItem.toGuiItem(event -> returnGui.show(player))); gui.setItem(23, this.guiSettings.parcelLockerSendItem.toGuiItem(event -> new SendingGui( this.scheduler, this.guiSettings, diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java new file mode 100644 index 00000000..5b248dea --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGui.java @@ -0,0 +1,118 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.parcel.Parcel; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.StorageGui; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; +import java.util.stream.IntStream; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** + * Deposit GUI for a parcel return: the player places the original items, then confirms. + * Confirm hands the stacks to the return service (which gives them back on any failure); + * closing without confirming gives everything back immediately. + */ +public class ReturnDepositGui { + + private final Scheduler scheduler; + private final GuiSettings guiSettings; + private final MiniMessage miniMessage; + private final GuiManager guiManager; + private final Parcel parcel; + private final BiFunction guiFactory; + + public ReturnDepositGui( + Scheduler scheduler, + GuiSettings guiSettings, + MiniMessage miniMessage, + GuiManager guiManager, + Parcel parcel + ) { + this( + scheduler, + guiSettings, + miniMessage, + guiManager, + parcel, + (title, rows) -> Gui.storage().title(title).rows(rows).create() + ); + } + + ReturnDepositGui( + Scheduler scheduler, + GuiSettings guiSettings, + MiniMessage miniMessage, + GuiManager guiManager, + Parcel parcel, + BiFunction guiFactory + ) { + this.scheduler = scheduler; + this.guiSettings = guiSettings; + this.miniMessage = miniMessage; + this.guiManager = guiManager; + this.parcel = parcel; + this.guiFactory = guiFactory; + } + + void show(Player player) { + int rows = switch (this.parcel.size()) { + case SMALL -> 2; + case MEDIUM -> 3; + case LARGE -> 4; + }; + + Component title = this.miniMessage.deserialize(this.guiSettings.parcelReturnDepositGuiTitle); + StorageGui gui = this.guiFactory.apply(title, rows); + + GuiItem backgroundItem = this.guiSettings.mainGuiBackgroundItem.toGuiItem(event -> event.setCancelled(true)); + IntStream.rangeClosed(1, 9).forEach(i -> gui.setItem(gui.getRows(), i, backgroundItem)); + + AtomicBoolean confirmed = new AtomicBoolean(false); + + GuiItem confirmItem = this.guiSettings.confirmReturnItem.toGuiItem(event -> { + event.setCancelled(true); + + List deposited = this.takeDepositedItems(gui); + confirmed.set(true); + gui.close(player); + this.guiManager.returnParcel(player, this.parcel, deposited); + }); + gui.setItem(gui.getRows(), 5, confirmItem); + + gui.setCloseGuiAction(event -> { + if (confirmed.get()) { + return; + } + List leftovers = this.takeDepositedItems(gui); + this.scheduler.run(() -> leftovers.forEach(item -> ItemUtil.giveItem(player, item))); + }); + + this.scheduler.run(() -> gui.open(player)); + } + + private List takeDepositedItems(StorageGui gui) { + ItemStack[] contents = gui.getInventory().getContents(); + List items = new ArrayList<>(); + + for (int i = 0; i < contents.length - 9; i++) { + ItemStack item = contents[i]; + if (item == null || item.isEmpty()) { + continue; + } + items.add(item.clone()); + gui.getInventory().setItem(i, null); + } + return items; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java new file mode 100644 index 00000000..d387d35d --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGui.java @@ -0,0 +1,184 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.gui.GuiView; +import com.eternalcode.parcellockers.gui.PaginatedGuiRefresher; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.util.PlaceholderUtil; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.util.DurationUtil; +import com.eternalcode.parcellockers.util.MaterialUtil; +import com.spotify.futures.CompletableFutures; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.Supplier; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class ReturnGui implements GuiView { + + private static final int WIDTH = 7; + private static final int HEIGHT = 4; + private static final Page FIRST_PAGE = new Page(0, WIDTH * HEIGHT); + + private final GuiSettings guiSettings; + private final Scheduler scheduler; + private final GuiManager guiManager; + private final MiniMessage miniMessage; + private final Function guiFactory; + + public ReturnGui( + GuiSettings guiSettings, + Scheduler scheduler, + GuiManager guiManager, + MiniMessage miniMessage + ) { + this( + guiSettings, + scheduler, + guiManager, + miniMessage, + title -> Gui.paginated() + .rows(6) + .disableAllInteractions() + .title(title) + .create() + ); + } + + ReturnGui( + GuiSettings guiSettings, + Scheduler scheduler, + GuiManager guiManager, + MiniMessage miniMessage, + Function guiFactory + ) { + this.guiSettings = guiSettings; + this.scheduler = scheduler; + this.guiManager = guiManager; + this.miniMessage = miniMessage; + this.guiFactory = guiFactory; + } + + @Override + public void show(Player player) { + this.show(player, FIRST_PAGE); + } + + @Override + public void show(Player player, Page page) { + Component guiTitle = this.miniMessage.deserialize(this.guiSettings.parcelReturnGuiTitle); + ConfigItem rowItem = this.guiSettings.parcelReturnRowItem; + + PaginatedGui gui = this.guiFactory.apply(guiTitle); + + this.setupStaticItems(player, gui); + + this.guiManager.getReturnableParcels(player.getUniqueId(), page).thenAccept(result -> { + if (result.items().isEmpty()) { + this.scheduler.run(() -> { + gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); + gui.open(player); + }); + return; + } + + result.items().stream() + .map(parcel -> this.createParcelItemAsync(parcel, rowItem, player)) + .collect(CompletableFutures.joinList()) + .thenAccept(suppliers -> this.scheduler.run(() -> { + if (suppliers.isEmpty()) { + gui.setItem(22, this.guiSettings.noReturnableParcelsItem.toGuiItem()); + gui.open(player); + return; + } + + this.setupNavigation(gui, page, result, player, this.guiSettings); + PaginatedGuiRefresher refresher = new PaginatedGuiRefresher(gui); + suppliers.forEach(refresher::addItem); + gui.open(player); + })) + .exceptionally(FutureHandler::handleException); + }).exceptionally(FutureHandler::handleException); + } + + private void setupStaticItems(Player player, PaginatedGui gui) { + GuiItem closeItem = this.guiSettings.closeItem.toGuiItem(event -> gui.close(player)); + GuiItem cornerItem = this.guiSettings.cornerItem.toGuiItem(); + GuiItem backgroundItem = this.guiSettings.mainGuiBackgroundItem.toGuiItem(); + + for (int cornerSlot : CORNER_SLOTS) { + gui.setItem(cornerSlot, cornerItem); + } + + for (int borderSlot : BORDER_SLOTS) { + gui.setItem(borderSlot, backgroundItem); + } + + gui.setItem(49, closeItem); + } + + private CompletableFuture> createParcelItemAsync( + Parcel parcel, + ConfigItem rowItem, + Player player + ) { + CompletableFuture> loreFuture = + PlaceholderUtil.replaceParcelPlaceholdersAsync(parcel, rowItem.lore(), this.guiManager); + CompletableFuture> contentFuture = this.guiManager.getParcelContent(parcel.uuid()) + .thenApply(optional -> optional.map(content -> content.items()).orElse(List.of())); + CompletableFuture windowLineFuture = this.guiManager.getCollectedInfo(parcel.uuid()) + .thenApply(optional -> optional + .map(collected -> { + Duration remaining = Duration.between(Instant.now(), collected.collectedAt().plus(this.guiManager.returnWindow())); + return remaining.isNegative() || remaining.isZero() + ? this.guiSettings.returnWindowExpiredLine + : this.guiSettings.returnWindowRemainingLine.replace("{DURATION}", DurationUtil.format(remaining)); + }) + .orElse(this.guiSettings.returnWindowExpiredLine)); + + return CompletableFutures.combine(loreFuture, contentFuture, windowLineFuture, (processedLore, items, windowLine) -> { + Supplier supplier = () -> { + ConfigItem item = rowItem.clone(); + item.name(item.name().replace("{NAME}", parcel.name())); + + List lore = new ArrayList<>(processedLore); + lore.add(windowLine); + if (!items.isEmpty()) { + lore.add(this.guiSettings.parcelItemsCollectionGui); + for (ItemStack itemStack : items) { + lore.add(this.guiSettings.parcelItemCollectionFormat + .replace("{ITEM}", MaterialUtil.format(itemStack.getType())) + .replace("{AMOUNT}", Integer.toString(itemStack.getAmount())) + ); + } + } + + item.lore(lore); + item.glow(true); + + return item.toGuiItem(event -> new ReturnDepositGui( + this.scheduler, + this.guiSettings, + this.miniMessage, + this.guiManager, + parcel + ).show(player)); + }; + return supplier; + }).toCompletableFuture(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java b/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java index 8744073b..6f8fb1e3 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/ParcelStatus.java @@ -3,5 +3,6 @@ public enum ParcelStatus { SENT, - DELIVERED + DELIVERED, + COLLECTED } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java new file mode 100644 index 00000000..c388b0d4 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/parcel/event/ParcelReturnEvent.java @@ -0,0 +1,30 @@ +package com.eternalcode.parcellockers.parcel.event; + +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.shared.event.CancellableEvent; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +public class ParcelReturnEvent extends CancellableEvent { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final Parcel parcel; + + public ParcelReturnEvent(Parcel parcel) { + this.parcel = parcel; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } + + public Parcel getParcel() { + return this.parcel; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLER_LIST; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java index 43f5b49f..81713f21 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepository.java @@ -1,6 +1,7 @@ package com.eternalcode.parcellockers.parcel.repository; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import java.util.List; @@ -15,6 +16,14 @@ public interface ParcelRepository { CompletableFuture update(Parcel parcel); + /** + * Conditionally persists every mutable field of {@code updated}, but only if the row's + * current status still equals {@code expectedStatus}. Returns false when the row moved out + * from under the caller (e.g. a concurrent collect), so a stale in-memory snapshot cannot + * clobber it. + */ + CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus); + CompletableFuture> findAll(); CompletableFuture> findPage(Page page); @@ -43,6 +52,15 @@ public interface ParcelRepository { */ CompletableFuture> findCollectible(UUID receiver, UUID destinationLocker, Page page); + /** + * Atomically flips a DELIVERED parcel to COLLECTED. Returns false when the parcel is missing + * or not DELIVERED — the caller must treat that as "someone else already collected it". + */ + CompletableFuture markCollected(UUID uuid); + + /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ + CompletableFuture> findReturnable(UUID receiver, Page page); + /** * Counts the parcels currently occupying a destination locker. Collected parcels are removed * from storage, so every parcel addressed to the locker (in-transit or delivered) occupies a diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java index 43c56008..a459542d 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelRepositoryOrmLite.java @@ -7,6 +7,7 @@ import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; +import com.j256.ormlite.stmt.UpdateBuilder; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -15,10 +16,16 @@ public class ParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implements ParcelRepository { + private static final String UUID_COLUMN = "uuid"; private static final String RECEIVER_COLUMN = "receiver"; private static final String SENDER_COLUMN = "sender"; private static final String DESTINATION_LOCKER_COLUMN = "destination_locker"; + private static final String ENTRY_LOCKER_COLUMN = "entry_locker"; private static final String STATUS_COLUMN = "status"; + private static final String NAME_COLUMN = "name"; + private static final String DESCRIPTION_COLUMN = "description"; + private static final String PRIORITY_COLUMN = "priority"; + private static final String SIZE_COLUMN = "size"; public ParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { super(databaseManager, scheduler); @@ -37,6 +44,29 @@ public CompletableFuture update(Parcel parcel) { return this.upsert(ParcelTable.class, ParcelTable.from(parcel)).thenApply(dao -> null); } + @Override + public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { + Objects.requireNonNull(updated, "Parcel cannot be null"); + Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); + return this.action(ParcelTable.class, dao -> { + UpdateBuilder builder = dao.updateBuilder(); + builder.updateColumnValue(SENDER_COLUMN, updated.sender()); + builder.updateColumnValue(NAME_COLUMN, updated.name()); + builder.updateColumnValue(DESCRIPTION_COLUMN, updated.description()); + builder.updateColumnValue(PRIORITY_COLUMN, updated.priority()); + builder.updateColumnValue(RECEIVER_COLUMN, updated.receiver()); + builder.updateColumnValue(SIZE_COLUMN, updated.size()); + builder.updateColumnValue(ENTRY_LOCKER_COLUMN, updated.entryLocker()); + builder.updateColumnValue(DESTINATION_LOCKER_COLUMN, updated.destinationLocker()); + builder.updateColumnValue(STATUS_COLUMN, updated.status()); + builder.where() + .eq(UUID_COLUMN, updated.uuid()) + .and() + .eq(STATUS_COLUMN, expectedStatus); + return builder.update() > 0; + }); + } + @Override public CompletableFuture> findById(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); @@ -96,6 +126,33 @@ public CompletableFuture> findCollectible(UUID receiver, UUID }, ParcelTable::toParcel); } + @Override + public CompletableFuture markCollected(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + return this.action(ParcelTable.class, dao -> { + UpdateBuilder builder = dao.updateBuilder(); + builder.updateColumnValue(STATUS_COLUMN, ParcelStatus.COLLECTED); + builder.where() + .eq(UUID_COLUMN, uuid) + .and() + .eq(STATUS_COLUMN, ParcelStatus.DELIVERED); + return builder.update() > 0; + }); + } + + @Override + public CompletableFuture> findReturnable(UUID receiver, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + return this.queryPage(ParcelTable.class, page, builder -> { + builder.where() + .eq(RECEIVER_COLUMN, receiver) + .and() + .eq(STATUS_COLUMN, ParcelStatus.COLLECTED); + return builder; + }, ParcelTable::toParcel); + } + @Override public CompletableFuture countParcelsByDestinationLocker(UUID destinationLocker) { Objects.requireNonNull(destinationLocker, "Destination locker UUID cannot be null"); @@ -103,6 +160,8 @@ public CompletableFuture countParcelsByDestinationLocker(UUID destinati long count = dao.queryBuilder() .where() .eq(DESTINATION_LOCKER_COLUMN, destinationLocker) + .and() + .ne(STATUS_COLUMN, ParcelStatus.COLLECTED) .countOf(); return (int) count; }); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java index 00592211..9b13f42b 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/repository/ParcelTable.java @@ -8,7 +8,7 @@ import java.util.UUID; @DatabaseTable(tableName = "parcels") -class ParcelTable { +public class ParcelTable { @DatabaseField(columnName = "uuid", id = true) private UUID uuid; diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java index 36b6e741..01547f50 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/AdminParcelService.java @@ -62,9 +62,23 @@ public CompletableFuture changeDescription(Parcel parcel, String des return this.persist(withDescription(parcel, description)); } + /** + * A COLLECTED parcel's content row is the collection snapshot, not a live shipment; flipping + * its status (e.g. back to SENT) would arm a delivery and hand the receiver the same items + * a second time. Refuse the change outright — a return is the only supported transition out + * of COLLECTED. + */ public CompletableFuture changeStatus(Parcel parcel, ParcelStatus status) { + if (parcel.status() == ParcelStatus.COLLECTED) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + } Parcel updated = withStatus(parcel, status); - return this.parcelService.update(updated).thenCompose(ignored -> { + // Conditional on the snapshot's status: a concurrent collect between the GUI opening and + // this edit landing must not resurrect a COLLECTED parcel back to SENT/DELIVERED. + return this.parcelService.updateIfStatus(updated, parcel.status()).thenCompose(applied -> { + if (!Boolean.TRUE.equals(applied)) { + return CompletableFuture.completedFuture(EditResult.of(EditResult.Status.PARCEL_COLLECTED)); + } if (status == parcel.status()) { return CompletableFuture.completedFuture(EditResult.ok()); } diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java index c245412e..d00ef3ad 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/EditResult.java @@ -2,7 +2,7 @@ public final class EditResult { - public enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL } + public enum Status { OK, SIZE_TOO_SMALL, DESTINATION_FULL, PARCEL_COLLECTED } private final Status status; diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java index abaa9cb5..26857b40 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelService.java @@ -2,6 +2,7 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import java.util.List; @@ -24,6 +25,12 @@ public interface ParcelService { CompletableFuture update(Parcel parcel); + /** + * Conditionally applies {@code updated}, refusing when the parcel's current status no + * longer matches {@code expectedStatus} (e.g. a concurrent collect raced an admin edit). + */ + CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus); + CompletableFuture collect(Player player, Parcel parcel); CompletableFuture delete(CommandSender sender, Parcel parcel); @@ -32,6 +39,8 @@ public interface ParcelService { CompletableFuture> get(UUID uuid); + void invalidate(UUID uuid); + CompletableFuture> getBySender(UUID sender, Page page); CompletableFuture> getByReceiver(UUID receiver, Page page); @@ -44,6 +53,9 @@ public interface ParcelService { */ CompletableFuture> getCollectible(UUID receiver, UUID destinationLocker, Page page); + /** Returns the COLLECTED parcels of the given receiver (candidates for a return). */ + CompletableFuture> getReturnable(UUID receiver, Page page); + CompletableFuture> getAll(Page page); CompletableFuture delete(UUID uuid); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java index 27a687f8..fbfe5778 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/service/ParcelServiceImpl.java @@ -10,15 +10,19 @@ import com.eternalcode.parcellockers.notification.NoticeService; import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import com.eternalcode.parcellockers.parcel.event.ParcelCollectEvent; import com.eternalcode.parcellockers.parcel.event.ParcelSendEvent; import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; import com.eternalcode.parcellockers.shared.Page; import com.eternalcode.parcellockers.shared.PageResult; import com.eternalcode.parcellockers.shared.exception.ParcelOperationException; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.base.Preconditions; +import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -43,6 +47,7 @@ public class ParcelServiceImpl implements ParcelService { private final NoticeService noticeService; private final ParcelRepository parcelRepository; private final ParcelContentRepository parcelContentRepository; + private final CollectedParcelRepository collectedParcelRepository; private final Scheduler scheduler; private final PluginConfig config; private final Economy economy; @@ -54,6 +59,7 @@ public ParcelServiceImpl( NoticeService noticeService, ParcelRepository parcelRepository, ParcelContentRepository parcelContentRepository, + CollectedParcelRepository collectedParcelRepository, Scheduler scheduler, PluginConfig config, Economy economy, @@ -62,6 +68,7 @@ public ParcelServiceImpl( this.noticeService = noticeService; this.parcelRepository = parcelRepository; this.parcelContentRepository = parcelContentRepository; + this.collectedParcelRepository = collectedParcelRepository; this.scheduler = scheduler; this.config = config; this.economy = economy; @@ -174,6 +181,20 @@ public CompletableFuture update(Parcel updated) { return this.parcelRepository.update(updated); } + @Override + public CompletableFuture updateIfStatus(Parcel updated, ParcelStatus expectedStatus) { + Objects.requireNonNull(updated, "Updated parcel cannot be null"); + Objects.requireNonNull(expectedStatus, "Expected status cannot be null"); + return this.parcelRepository.updateIfStatus(updated, expectedStatus).thenApply(applied -> { + if (applied) { + this.parcelsByUuid.put(updated.uuid(), updated); + } else { + this.parcelsByUuid.invalidate(updated.uuid()); + } + return applied; + }); + } + @Override public CompletableFuture delete(CommandSender sender, Parcel parcel) { Objects.requireNonNull(sender, "Sender cannot be null"); @@ -220,8 +241,11 @@ public CompletableFuture collect(Player player, Parcel parcel) { CompletableFuture result = new CompletableFuture<>(); // Re-check inventory space on the main thread (the previous async check was a TOCTOU), - // then delete the parcel BEFORE handing the items back so it cannot be collected twice. - // Items are only given once the delete is confirmed, so a failed delete never destroys them. + // then flip the status BEFORE handing the items back so the parcel cannot be collected + // twice. The parcel and content rows are kept: they are the snapshot a later return is + // validated against. The collected_parcels row is written first so that a successful + // flip always has a collection timestamp; a stray row from a failed flip is ignored by + // the purge task (it only purges parcels that are actually COLLECTED). this.scheduler.run(() -> { if (!canHold(player, items)) { this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.noInventorySpace); @@ -229,31 +253,17 @@ public CompletableFuture collect(Player player, Parcel parcel) { return; } - this.parcelRepository.delete(parcel) - .thenCompose(deleted -> { - if (!deleted) { - return CompletableFuture.completedFuture(false); - } - // The parcel is gone, so it can never be collected again; deleting its content - // is best-effort cleanup and must not block handing the items back. Otherwise a - // failed content delete would lose the items permanently. - return this.parcelContentRepository.delete(parcel.uuid()) - .handle((contentDeleted, throwable) -> { - if (throwable != null) { - this.server.getLogger().warning("Failed to delete content for collected parcel " - + parcel.uuid() + ": " + throwable.getMessage()); - } - return true; - }); - }) - .thenAccept(removed -> { - if (!removed) { - this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.databaseError); + this.collectedParcelRepository.save(new CollectedParcel(parcel.uuid(), Instant.now())) + .thenCompose(saved -> this.parcelRepository.markCollected(parcel.uuid())) + .thenAccept(marked -> { + if (!Boolean.TRUE.equals(marked)) { + // Someone else collected it first (or the status changed under us). + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotCollect); result.complete(null); return; } - this.parcelsByUuid.invalidate(parcel.uuid()); + this.parcelsByUuid.put(parcel.uuid(), withStatus(parcel, ParcelStatus.COLLECTED)); this.scheduler.run(() -> { items.forEach(item -> ItemUtil.giveItem(player, item)); this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.collected); @@ -271,6 +281,12 @@ public CompletableFuture collect(Player player, Parcel parcel) { }); } + private static Parcel withStatus(Parcel parcel, ParcelStatus status) { + return new Parcel(parcel.uuid(), parcel.sender(), parcel.name(), parcel.description(), + parcel.priority(), parcel.receiver(), parcel.size(), parcel.entryLocker(), + parcel.destinationLocker(), status); + } + @Override public CompletableFuture> get(UUID uuid) { Objects.requireNonNull(uuid, "UUID cannot be null"); @@ -285,6 +301,12 @@ public CompletableFuture> get(UUID uuid) { }); } + @Override + public void invalidate(UUID uuid) { + Objects.requireNonNull(uuid, "UUID cannot be null"); + this.parcelsByUuid.invalidate(uuid); + } + @Override public CompletableFuture> getBySender(UUID sender, Page page) { Objects.requireNonNull(sender, "Sender UUID cannot be null"); @@ -321,6 +343,18 @@ public CompletableFuture> getCollectible(UUID receiver, UUID }); } + @Override + public CompletableFuture> getReturnable(UUID receiver, Page page) { + Objects.requireNonNull(receiver, "Receiver UUID cannot be null"); + Objects.requireNonNull(page, "Page cannot be null"); + + return this.parcelRepository.findReturnable(receiver, page) + .thenApply(result -> { + result.items().forEach(parcel -> this.parcelsByUuid.put(parcel.uuid(), parcel)); + return result; + }); + } + @Override public CompletableFuture> getAll(Page page) { Objects.requireNonNull(page, "Page cannot be null"); diff --git a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java index 49c4eaee..d06f9d75 100644 --- a/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java +++ b/src/main/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTask.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.Optional; import java.util.UUID; +import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitRunnable; @@ -35,7 +36,7 @@ public ParcelSendTask(Parcel parcel, ParcelService parcelService, DeliveryManage /** Pure decision: what to do given the latest parcel + delivery state at fire time. */ public static Decision decide(Optional currentParcel, Optional currentDelivery, Instant now) { - if (currentParcel.isEmpty() || currentParcel.get().status() == ParcelStatus.DELIVERED) { + if (currentParcel.isEmpty() || currentParcel.get().status() != ParcelStatus.SENT) { return Decision.ABORT; } if (currentDelivery.isPresent() && currentDelivery.get().deliveryTimestamp().isAfter(now)) { @@ -54,7 +55,7 @@ public void run() { // Parcel gone or already delivered: clean up any stray delivery row. optionalDelivery.ifPresent(delivery -> this.deliveryManager.delete(this.parcelId).exceptionally(throwable -> { - LOGGER.severe("Failed to delete stray delivery for " + this.parcelId + ": " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to delete stray delivery for " + this.parcelId, throwable); return false; })); case RESCHEDULE -> { @@ -67,7 +68,7 @@ public void run() { case DELIVER -> this.deliver(optionalParcel.get()); } })).exceptionally(throwable -> { - LOGGER.severe("ParcelSendTask failed for " + this.parcelId + ": " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "ParcelSendTask failed for " + this.parcelId, throwable); return null; }); } @@ -87,8 +88,8 @@ private void deliver(Parcel current) { this.parcelService.update(delivered) .thenCompose(ignored -> this.deliveryManager.delete(delivered.uuid())) .exceptionally(throwable -> { - LOGGER.severe("Failed to deliver parcel " + delivered.uuid() - + " (delivery left for retry): " + throwable.getMessage()); + LOGGER.log(Level.SEVERE, "Failed to deliver parcel " + delivered.uuid() + + " (delivery left for retry)", throwable); return null; }); } diff --git a/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java b/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java new file mode 100644 index 00000000..23fe4567 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/CollectedParcel.java @@ -0,0 +1,7 @@ +package com.eternalcode.parcellockers.returns; + +import java.time.Instant; +import java.util.UUID; + +public record CollectedParcel(UUID parcel, Instant collectedAt) { +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java new file mode 100644 index 00000000..daf55375 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnService.java @@ -0,0 +1,289 @@ +package com.eternalcode.parcellockers.returns; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.provider.NoticeProvider; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.event.ParcelReturnEvent; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.parcel.task.ParcelSendTask; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; +import net.milkbowl.vault.economy.Economy; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** + * Orchestrates returning a collected parcel: validates the deposited items against the stored + * content snapshot, charges the return fee, flips the parcel into a reverse SENT shipment and + * schedules the normal delivery task. Every abort path hands the deposited items back. + */ +public class ParcelReturnService { + + private static final Logger LOGGER = Logger.getLogger(ParcelReturnService.class.getName()); + private static final String PARCEL_FEE_BYPASS_PERMISSION = "parcellockers.fee.bypass"; + private static final String PLACEHOLDER_AMOUNT = "{AMOUNT}"; + + private final ParcelService parcelService; + private final ParcelContentManager parcelContentManager; + private final CollectedParcelRepository collectedParcelRepository; + private final DeliveryManager deliveryManager; + private final LockerManager lockerManager; + private final ParcelReturnValidator validator; + private final ReturnMismatchFormatter mismatchFormatter; + private final ParcelReturnRepository parcelReturnRepository; + private final Scheduler scheduler; + private final PluginConfig config; + private final NoticeService noticeService; + private final Economy economy; + private final Server server; + + public ParcelReturnService( + ParcelService parcelService, + ParcelContentManager parcelContentManager, + CollectedParcelRepository collectedParcelRepository, + DeliveryManager deliveryManager, + LockerManager lockerManager, + ParcelReturnValidator validator, + ReturnMismatchFormatter mismatchFormatter, + ParcelReturnRepository parcelReturnRepository, + Scheduler scheduler, + PluginConfig config, + NoticeService noticeService, + Economy economy, + Server server + ) { + this.parcelService = parcelService; + this.parcelContentManager = parcelContentManager; + this.collectedParcelRepository = collectedParcelRepository; + this.deliveryManager = deliveryManager; + this.lockerManager = lockerManager; + this.validator = validator; + this.mismatchFormatter = mismatchFormatter; + this.parcelReturnRepository = parcelReturnRepository; + this.scheduler = scheduler; + this.config = config; + this.noticeService = noticeService; + this.economy = economy; + this.server = server; + } + + public static boolean isWithinReturnWindow(CollectedParcel collected, Duration window, Instant now) { + return collected.collectedAt().plus(window).isAfter(now); + } + + public CompletableFuture> getCollectedInfo(UUID parcelId) { + Objects.requireNonNull(parcelId, "Parcel UUID cannot be null"); + return this.collectedParcelRepository.find(parcelId); + } + + public CompletableFuture returnParcel(Player player, Parcel parcel, List deposited) { + Objects.requireNonNull(player, "Player cannot be null"); + Objects.requireNonNull(parcel, "Parcel cannot be null"); + Objects.requireNonNull(deposited, "Deposited items cannot be null"); + + ParcelReturnEvent event = new ParcelReturnEvent(parcel); + this.server.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + boolean feeBypassed = player.hasPermission(PARCEL_FEE_BYPASS_PERMISSION); + + return this.parcelService.get(parcel.uuid()).thenCompose(optionalParcel -> { + if (optionalParcel.isEmpty() + || optionalParcel.get().status() != ParcelStatus.COLLECTED + || !player.getUniqueId().equals(optionalParcel.get().receiver())) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + Parcel current = optionalParcel.get(); + + return this.collectedParcelRepository.find(current.uuid()).thenCompose(optionalCollected -> { + if (optionalCollected.isEmpty() + || !isWithinReturnWindow(optionalCollected.get(), this.config.settings.parcelReturnWindow, Instant.now())) { + return this.abort(player, deposited, messages -> messages.parcel.returnWindowExpired); + } + + return this.parcelContentManager.get(current.uuid()).thenCompose(optionalContent -> { + if (optionalContent.isEmpty()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + ParcelReturnValidationResult validation = this.validator.validate(deposited, optionalContent.get().items()); + if (!validation.matches()) { + return this.abortMismatch(player, deposited, validation); + } + + // The return ships to the original entry locker. It may have been deleted + // while the parcel was in the receiver's return window. + return this.lockerManager.get(current.entryLocker()).thenCompose(locker -> { + if (locker.isEmpty()) { + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + return this.lockerManager.isLockerFull(current.entryLocker()).thenCompose(isFull -> { + if (Boolean.TRUE.equals(isFull)) { + return this.abort(player, deposited, messages -> messages.parcel.lockerFull); + } + return this.execute(player, current, deposited, feeBypassed); + }); + }); + }); + }); + }).exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "Failed to return parcel " + parcel.uuid() + " for " + player.getName(), throwable); + this.giveBack(player, deposited); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotReturn); + return null; + }); + } + + private CompletableFuture execute( + Player player, + Parcel current, + List deposited, + boolean feeBypassed + ) { + double fee = feeBypassed ? 0 : this.returnFeeFor(current.size()); + if (fee <= 0) { + return this.proceedWithReturn(player, current, deposited, 0); + } + + // Vault economy providers expect withdrawPlayer to run on the primary thread; by this + // point the call chain has already hopped onto a repository/DB executor thread, so the + // withdrawal must be scheduled back onto the main thread rather than called in place. + return this.scheduler.complete(() -> this.economy.withdrawPlayer(player, fee).transactionSuccess()) + .thenCompose(success -> { + if (!Boolean.TRUE.equals(success)) { + this.noticeService.create() + .notice(messages -> messages.parcel.insufficientFunds) + .player(player.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, String.format("%.2f", fee)) + .send(); + this.giveBack(player, deposited); + return CompletableFuture.completedFuture(null); + } + // The fee-withdrawn notice is sent only after the atomic return commit succeeds: + // if this attempt loses the race and the fee is refunded, the player must not + // already have been told the fee was withdrawn. + return this.proceedWithReturn(player, current, deposited, fee); + }); + } + + private CompletableFuture proceedWithReturn(Player player, Parcel current, List deposited, double refundableFee) { + String returnedName = this.config.settings.parcelReturnNameFormat.replace("{NAME}", current.name()); + Parcel returned = new Parcel(current.uuid(), current.receiver(), returnedName, + current.description(), current.priority(), current.sender(), current.size(), + current.destinationLocker(), current.entryLocker(), ParcelStatus.SENT); + + List depositedCopy = deposited.stream().map(ItemStack::clone).toList(); + Duration delay = returned.priority() + ? this.config.settings.priorityParcelSendDuration + : this.config.settings.parcelSendDuration; + Delivery delivery = new Delivery(returned.uuid(), Instant.now().plus(delay)); + + // Claim, content replacement, delivery persistence and collected-row cleanup are one + // transaction. A losing concurrent attempt cannot overwrite the winner's content. + return this.parcelReturnRepository.commit( + returned, + new ParcelContent(returned.uuid(), depositedCopy), + delivery + ).thenCompose(committed -> { + if (!Boolean.TRUE.equals(committed)) { + this.refund(player, refundableFee); + return this.abort(player, deposited, messages -> messages.parcel.cannotReturn); + } + + // Past the commit point the durable delivery row makes this shipment recoverable at + // startup. Nothing below may route to refund/give-back recovery. + try { + this.parcelService.invalidate(returned.uuid()); + this.parcelContentManager.invalidate(returned.uuid()); + this.deliveryManager.invalidate(returned.uuid()); + + if (refundableFee > 0) { + this.noticeService.create() + .notice(messages -> messages.parcel.returnFeeWithdrawn) + .player(player.getUniqueId()) + .placeholder(PLACEHOLDER_AMOUNT, String.format("%.2f", refundableFee)) + .send(); + } + + this.scheduler.runLaterAsync( + new ParcelSendTask(returned, this.parcelService, this.deliveryManager, this.scheduler), + delay); + + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.returned); + } catch (Exception exception) { + LOGGER.log(Level.SEVERE, "Post-commit step threw for returned parcel " + current.uuid() + + ": the return is already committed (parcel is SENT, content holds the deposited " + + "items) so no refund/give-back recovery was attempted", exception); + } + return CompletableFuture.completedFuture(null); + }) + .exceptionally(throwable -> { + this.refund(player, refundableFee); + this.giveBack(player, deposited); + this.noticeService.player(player.getUniqueId(), messages -> messages.parcel.cannotReturn); + LOGGER.log(Level.SEVERE, "Failed to execute return of parcel " + current.uuid(), throwable); + return null; + }); + } + + private double returnFeeFor(ParcelSize size) { + return switch (size) { + case SMALL -> this.config.settings.smallParcelReturnFee; + case MEDIUM -> this.config.settings.mediumParcelReturnFee; + case LARGE -> this.config.settings.largeParcelReturnFee; + }; + } + + private void refund(Player player, double fee) { + if (fee > 0) { + // Same primary-thread requirement as the withdrawal in execute(): this runs from + // async continuations, so the deposit must be dispatched back onto the main thread. + this.scheduler.run(() -> this.economy.depositPlayer(player, fee)); + } + } + + private CompletableFuture abort(Player player, List deposited, NoticeProvider notice) { + this.giveBack(player, deposited); + this.noticeService.player(player.getUniqueId(), notice); + return CompletableFuture.completedFuture(null); + } + + private CompletableFuture abortMismatch( + Player player, + List deposited, + ParcelReturnValidationResult validation + ) { + this.giveBack(player, deposited); + this.noticeService.create() + .notice(this.mismatchFormatter.notice(validation)) + .player(player.getUniqueId()) + .placeholder("{MISMATCHES}", this.mismatchFormatter.format(validation)) + .send(); + return CompletableFuture.completedFuture(null); + } + + private void giveBack(Player player, List items) { + this.scheduler.run(() -> items.forEach(item -> ItemUtil.giveItem(player, item))); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java new file mode 100644 index 00000000..3b47877c --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidationResult.java @@ -0,0 +1,14 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.List; + +public record ParcelReturnValidationResult(List mismatches) { + + public ParcelReturnValidationResult { + mismatches = List.copyOf(mismatches); + } + + public boolean matches() { + return this.mismatches.isEmpty(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java new file mode 100644 index 00000000..828342ac --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ParcelReturnValidator.java @@ -0,0 +1,173 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +/** + * Validates deposited items as a multiset. Physical stack splitting and merging do not + * affect validation. Exact variants are consumed before altered variants are diagnosed. + */ +public class ParcelReturnValidator { + + private final ReturnItemComparator comparator; + + public ParcelReturnValidator(ReturnItemComparator comparator) { + this.comparator = comparator; + } + + public ParcelReturnValidationResult validate(List deposited, List expected) { + Objects.requireNonNull(deposited, "Deposited items cannot be null"); + Objects.requireNonNull(expected, "Expected items cannot be null"); + + Set mismatches = new LinkedHashSet<>(); + Map expectedTotals = totals(expected); + Map depositedTotals = totals(deposited); + + for (Map.Entry entry : expectedTotals.entrySet()) { + int expectedAmount = entry.getValue(); + int depositedAmount = depositedTotals.getOrDefault(entry.getKey(), 0); + if (depositedAmount < expectedAmount) { + mismatches.add(ReturnItemMismatch.insufficient(entry.getKey(), expectedAmount, depositedAmount)); + } else if (depositedAmount > expectedAmount) { + mismatches.add(ReturnItemMismatch.excess(entry.getKey(), expectedAmount, depositedAmount)); + } + } + for (Map.Entry entry : depositedTotals.entrySet()) { + if (!expectedTotals.containsKey(entry.getKey())) { + mismatches.add(ReturnItemMismatch.unexpected(entry.getKey(), entry.getValue())); + } + } + + List expectedCandidates = candidates(expected); + List depositedCandidates = candidates(deposited); + this.consumeExact(expectedCandidates, depositedCandidates); + this.diagnoseClosest(expectedCandidates, depositedCandidates, mismatches); + + return new ParcelReturnValidationResult(List.copyOf(mismatches)); + } + + public boolean matches(List deposited, List expected) { + return this.validate(deposited, expected).matches(); + } + + private void consumeExact(List expected, List deposited) { + for (Candidate expectedItem : expected) { + for (Candidate depositedItem : deposited) { + if (expectedItem.remaining == 0 || depositedItem.remaining == 0) { + continue; + } + if (this.comparator.test(expectedItem.item, depositedItem.item)) { + consume(expectedItem, depositedItem); + } + } + } + } + + private void diagnoseClosest( + List expected, + List deposited, + Set mismatches + ) { + while (true) { + Pair closest = this.closestPair(expected, deposited); + if (closest == null) { + return; + } + Candidate expectedItem = expected.get(closest.expectedIndex); + Candidate depositedItem = deposited.get(closest.depositedIndex); + for (ReturnItemDifference difference : closest.differences) { + mismatches.add(ReturnItemMismatch.attribute( + mismatchType(difference), + expectedItem.item, + depositedItem.item + )); + } + consume(expectedItem, depositedItem); + } + } + + private Pair closestPair(List expected, List deposited) { + Pair closest = null; + for (int expectedIndex = 0; expectedIndex < expected.size(); expectedIndex++) { + Candidate expectedItem = expected.get(expectedIndex); + if (expectedItem.remaining == 0) { + continue; + } + for (int depositedIndex = 0; depositedIndex < deposited.size(); depositedIndex++) { + Candidate depositedItem = deposited.get(depositedIndex); + if (depositedItem.remaining == 0 || expectedItem.item.getType() != depositedItem.item.getType()) { + continue; + } + EnumSet differences = this.comparator.differences( + expectedItem.item, + depositedItem.item + ); + if (differences.isEmpty() || differences.contains(ReturnItemDifference.MATERIAL)) { + continue; + } + if (closest == null || differences.size() < closest.differences.size()) { + closest = new Pair(expectedIndex, depositedIndex, EnumSet.copyOf(differences)); + } + } + } + return closest; + } + + private static ReturnMismatchType mismatchType(ReturnItemDifference difference) { + return switch (difference) { + case DURABILITY -> ReturnMismatchType.DURABILITY; + case ITEM_NAME -> ReturnMismatchType.ITEM_NAME; + case ENCHANTMENTS -> ReturnMismatchType.ENCHANTMENTS; + case LORE -> ReturnMismatchType.LORE; + case NBT -> ReturnMismatchType.NBT; + case MATERIAL -> throw new IllegalArgumentException("Material differences are not attribute mismatches"); + }; + } + + private static Map totals(List items) { + Map totals = new LinkedHashMap<>(); + for (ItemStack item : items) { + totals.merge(item.getType(), item.getAmount(), Integer::sum); + } + return totals; + } + + private static List candidates(List items) { + List candidates = new ArrayList<>(items.size()); + for (ItemStack item : items) { + candidates.add(new Candidate(item)); + } + return candidates; + } + + private static void consume(Candidate expected, Candidate deposited) { + int amount = Math.min(expected.remaining, deposited.remaining); + expected.remaining -= amount; + deposited.remaining -= amount; + } + + private static final class Candidate { + private final ItemStack item; + private int remaining; + + private Candidate(ItemStack item) { + this.item = item; + this.remaining = item.getAmount(); + } + } + + private record Pair( + int expectedIndex, + int depositedIndex, + EnumSet differences + ) { + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java new file mode 100644 index 00000000..f590ad45 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemComparator.java @@ -0,0 +1,16 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.EnumSet; +import java.util.function.BiPredicate; +import org.bukkit.inventory.ItemStack; + +@FunctionalInterface +public interface ReturnItemComparator extends BiPredicate { + + EnumSet differences(ItemStack expected, ItemStack deposited); + + @Override + default boolean test(ItemStack expected, ItemStack deposited) { + return this.differences(expected, deposited).isEmpty(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java new file mode 100644 index 00000000..ded6608d --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemDifference.java @@ -0,0 +1,10 @@ +package com.eternalcode.parcellockers.returns; + +public enum ReturnItemDifference { + MATERIAL, + DURABILITY, + ITEM_NAME, + ENCHANTMENTS, + LORE, + NBT +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java new file mode 100644 index 00000000..41eed614 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalence.java @@ -0,0 +1,90 @@ +package com.eternalcode.parcellockers.returns; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.kyori.adventure.text.Component; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Config-driven equivalence between a deposited item and an original parcel item. + * Material must always match; amounts are compared by {@link ParcelReturnValidator}, + * not here. The relation is symmetric. + */ +public class ReturnItemEquivalence implements ReturnItemComparator { + + private final PluginConfig.ReturnChecks checks; + + public ReturnItemEquivalence(PluginConfig.ReturnChecks checks) { + this.checks = checks; + } + + @Override + public EnumSet differences(ItemStack expected, ItemStack deposited) { + EnumSet differences = EnumSet.noneOf(ReturnItemDifference.class); + if (expected.getType() != deposited.getType()) { + differences.add(ReturnItemDifference.MATERIAL); + return differences; + } + + ItemMeta expectedMeta = expected.getItemMeta(); + ItemMeta depositedMeta = deposited.getItemMeta(); + if (this.checks.checkDurability && damage(expectedMeta) != damage(depositedMeta)) { + differences.add(ReturnItemDifference.DURABILITY); + } + if (this.checks.checkItemName && !Objects.equals(displayName(expectedMeta), displayName(depositedMeta))) { + differences.add(ReturnItemDifference.ITEM_NAME); + } + if (this.checks.checkEnchantments && !enchants(expectedMeta).equals(enchants(depositedMeta))) { + differences.add(ReturnItemDifference.ENCHANTMENTS); + } + if (this.checks.checkLore && !Objects.equals(lore(expectedMeta), lore(depositedMeta))) { + differences.add(ReturnItemDifference.LORE); + } + if (this.checks.checkNbt && !this.residualDataMatches(expected, deposited)) { + differences.add(ReturnItemDifference.NBT); + } + return differences; + } + + private boolean residualDataMatches(ItemStack expected, ItemStack deposited) { + return this.normalizeResidualData(expected).isSimilar(this.normalizeResidualData(deposited)); + } + + private ItemStack normalizeResidualData(ItemStack item) { + ItemStack copy = item.clone(); + ItemMeta meta = copy.getItemMeta(); + if (meta == null) { + return copy; + } + if (meta instanceof Damageable damageable) { + damageable.setDamage(0); + } + meta.displayName(null); + List.copyOf(meta.getEnchants().keySet()).forEach(meta::removeEnchant); + meta.lore(null); + copy.setItemMeta(meta); + return copy; + } + + private static int damage(ItemMeta meta) { + return meta instanceof Damageable damageable ? damageable.getDamage() : 0; + } + + private static Component displayName(ItemMeta meta) { + return meta == null ? null : meta.displayName(); + } + + private static Map enchants(ItemMeta meta) { + return meta == null ? Map.of() : meta.getEnchants(); + } + + private static List lore(ItemMeta meta) { + return meta == null ? null : meta.lore(); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java new file mode 100644 index 00000000..d4551b41 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnItemMismatch.java @@ -0,0 +1,50 @@ +package com.eternalcode.parcellockers.returns; + +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +public record ReturnItemMismatch( + ReturnMismatchType type, + Material item, + int expectedAmount, + int depositedAmount, + Integer expectedDamage, + Integer depositedDamage +) { + + public ReturnItemMismatch { + Objects.requireNonNull(type, "Mismatch type cannot be null"); + Objects.requireNonNull(item, "Mismatch item cannot be null"); + } + + public static ReturnItemMismatch unexpected(Material item, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.UNEXPECTED_ITEM, item, 0, deposited, null, null); + } + + public static ReturnItemMismatch insufficient(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.INSUFFICIENT_AMOUNT, item, expected, deposited, null, null); + } + + public static ReturnItemMismatch excess(Material item, int expected, int deposited) { + return new ReturnItemMismatch(ReturnMismatchType.EXCESS_AMOUNT, item, expected, deposited, null, null); + } + + public static ReturnItemMismatch attribute(ReturnMismatchType type, ItemStack expected, ItemStack deposited) { + if (type == ReturnMismatchType.UNEXPECTED_ITEM + || type == ReturnMismatchType.INSUFFICIENT_AMOUNT + || type == ReturnMismatchType.EXCESS_AMOUNT) { + throw new IllegalArgumentException("Amount mismatch cannot be created as an attribute mismatch"); + } + Integer expectedDamage = type == ReturnMismatchType.DURABILITY ? damage(expected) : null; + Integer depositedDamage = type == ReturnMismatchType.DURABILITY ? damage(deposited) : null; + return new ReturnItemMismatch(type, expected.getType(), 0, 0, expectedDamage, depositedDamage); + } + + private static int damage(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + return meta instanceof Damageable damageable ? damageable.getDamage() : 0; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java new file mode 100644 index 00000000..4c21ea4d --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatter.java @@ -0,0 +1,73 @@ +package com.eternalcode.parcellockers.returns; + +import com.eternalcode.multification.notice.Notice; +import com.eternalcode.multification.notice.resolver.chat.ChatContent; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.util.MaterialUtil; +import java.util.Objects; +import java.util.stream.Collectors; + +public class ReturnMismatchFormatter { + + private final MessageConfig.ParcelMessages messages; + + public ReturnMismatchFormatter(MessageConfig.ParcelMessages messages) { + this.messages = Objects.requireNonNull(messages, "Parcel messages cannot be null"); + } + + public String format(ParcelReturnValidationResult result) { + requireMismatch(result); + return result.mismatches().stream() + .map(this::format) + .collect(Collectors.joining(this.messages.returnMismatchSeparator)); + } + + public Notice notice(ParcelReturnValidationResult result) { + requireMismatch(result); + Notice.Builder builder = Notice.builder(); + boolean containsPlaceholder = false; + for (var part : this.messages.returnItemsMismatch.parts()) { + builder.withPart(part); + if (part.content() instanceof ChatContent chat + && chat.messages().stream().anyMatch(message -> message.contains("{MISMATCHES}"))) { + containsPlaceholder = true; + } + } + if (!containsPlaceholder) { + builder.chat("{MISMATCHES}"); + } + return builder.build(); + } + + private static void requireMismatch(ParcelReturnValidationResult result) { + Objects.requireNonNull(result, "Validation result cannot be null"); + if (result.matches()) { + throw new IllegalArgumentException("Matching return has no mismatch reasons"); + } + } + + private String format(ReturnItemMismatch mismatch) { + String template = switch (mismatch.type()) { + case UNEXPECTED_ITEM -> this.messages.returnMismatchUnexpectedItem; + case INSUFFICIENT_AMOUNT -> this.messages.returnMismatchInsufficientAmount; + case EXCESS_AMOUNT -> this.messages.returnMismatchExcessAmount; + case DURABILITY -> this.messages.returnMismatchDurability; + case ITEM_NAME -> this.messages.returnMismatchItemName; + case ENCHANTMENTS -> this.messages.returnMismatchEnchantments; + case LORE -> this.messages.returnMismatchLore; + case NBT -> this.messages.returnMismatchNbt; + }; + + String formatted = template + .replace("{ITEM}", MaterialUtil.format(mismatch.item())) + .replace("{EXPECTED_AMOUNT}", Integer.toString(mismatch.expectedAmount())) + .replace("{DEPOSITED_AMOUNT}", Integer.toString(mismatch.depositedAmount())); + if (mismatch.expectedDamage() != null) { + formatted = formatted.replace("{EXPECTED_DAMAGE}", mismatch.expectedDamage().toString()); + } + if (mismatch.depositedDamage() != null) { + formatted = formatted.replace("{DEPOSITED_DAMAGE}", mismatch.depositedDamage().toString()); + } + return formatted; + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java new file mode 100644 index 00000000..2a47255e --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/ReturnMismatchType.java @@ -0,0 +1,12 @@ +package com.eternalcode.parcellockers.returns; + +public enum ReturnMismatchType { + UNEXPECTED_ITEM, + INSUFFICIENT_AMOUNT, + EXCESS_AMOUNT, + DURABILITY, + ITEM_NAME, + ENCHANTMENTS, + LORE, + NBT +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java new file mode 100644 index 00000000..fd2064f4 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepository.java @@ -0,0 +1,20 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.parcellockers.returns.CollectedParcel; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface CollectedParcelRepository { + + CompletableFuture save(CollectedParcel collectedParcel); + + CompletableFuture> find(UUID parcel); + + /** Returns rows collected at or before the given cutoff (i.e. whose return window expired). */ + CompletableFuture> findExpired(Instant cutoff); + + CompletableFuture delete(UUID parcel); +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java new file mode 100644 index 00000000..3e9ffe6c --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelRepositoryOrmLite.java @@ -0,0 +1,57 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.database.DatabaseManager; +import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class CollectedParcelRepositoryOrmLite extends AbstractRepositoryOrmLite implements CollectedParcelRepository { + + public CollectedParcelRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { + super(databaseManager, scheduler); + this.createTable(CollectedParcelTable.class); + } + + @Override + public CompletableFuture save(CollectedParcel collectedParcel) { + Objects.requireNonNull(collectedParcel, "CollectedParcel cannot be null"); + // Upsert, not insert-if-absent: a re-collect after a failed best-effort delete of the + // previous row must overwrite the stale collectedAt, not silently keep it (which would + // shorten the new return window by however long the parcel spent on its second trip). + return this.upsert(CollectedParcelTable.class, CollectedParcelTable.from(collectedParcel)) + .thenApply(status -> null); + } + + @Override + public CompletableFuture> find(UUID parcel) { + Objects.requireNonNull(parcel, "Parcel UUID cannot be null"); + return this.selectSafe(CollectedParcelTable.class, parcel) + .thenApply(optional -> optional.map(CollectedParcelTable::toCollectedParcel)); + } + + @Override + public CompletableFuture> findExpired(Instant cutoff) { + Objects.requireNonNull(cutoff, "Cutoff cannot be null"); + // collected_at is persisted as an ISO-8601 string (InstantPersister); a SQL range operator + // would compare it lexicographically, which misorders same-second values with different + // fractional renderings. Compare temporally in Java instead — the table only holds parcels + // inside the return window, so a full scan per purge run is cheap. + return this.selectAll(CollectedParcelTable.class) + .thenApply(rows -> rows.stream() + .map(CollectedParcelTable::toCollectedParcel) + .filter(collected -> !collected.collectedAt().isAfter(cutoff)) + .toList()); + } + + @Override + public CompletableFuture delete(UUID parcel) { + Objects.requireNonNull(parcel, "Parcel UUID cannot be null"); + return this.deleteById(CollectedParcelTable.class, parcel).thenApply(rows -> rows > 0); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java new file mode 100644 index 00000000..db041874 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/CollectedParcelTable.java @@ -0,0 +1,36 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.parcellockers.database.persister.InstantPersister; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.time.Instant; +import java.util.UUID; + +@DatabaseTable(tableName = "collected_parcels") +public class CollectedParcelTable { + + static final String COLLECTED_AT_COLUMN = "collected_at"; + + @DatabaseField(id = true) + private UUID parcel; + + @DatabaseField(columnName = COLLECTED_AT_COLUMN, persisterClass = InstantPersister.class) + private Instant collectedAt; + + CollectedParcelTable() { + } + + CollectedParcelTable(UUID parcel, Instant collectedAt) { + this.parcel = parcel; + this.collectedAt = collectedAt; + } + + static CollectedParcelTable from(CollectedParcel collectedParcel) { + return new CollectedParcelTable(collectedParcel.parcel(), collectedParcel.collectedAt()); + } + + CollectedParcel toCollectedParcel() { + return new CollectedParcel(this.parcel, this.collectedAt); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java new file mode 100644 index 00000000..3c959a15 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepository.java @@ -0,0 +1,16 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.parcel.Parcel; +import java.util.concurrent.CompletableFuture; + +public interface ParcelReturnRepository { + + /** + * Atomically claims a collected parcel for return and persists every durable part of the + * reverse shipment. Returns false when another operation already moved the parcel out of + * COLLECTED. + */ + CompletableFuture commit(Parcel returned, ParcelContent content, Delivery delivery); +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java new file mode 100644 index 00000000..c9793cd3 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/repository/ParcelReturnRepositoryOrmLite.java @@ -0,0 +1,61 @@ +package com.eternalcode.parcellockers.returns.repository; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.repository.ParcelContentTable; +import com.eternalcode.parcellockers.database.DatabaseManager; +import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.repository.DeliveryTable; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelTable; +import com.j256.ormlite.misc.TransactionManager; +import com.j256.ormlite.stmt.UpdateBuilder; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +public class ParcelReturnRepositoryOrmLite extends AbstractRepositoryOrmLite + implements ParcelReturnRepository { + + public ParcelReturnRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { + super(databaseManager, scheduler); + } + + @Override + public CompletableFuture commit( + Parcel returned, + ParcelContent content, + Delivery delivery + ) { + Objects.requireNonNull(returned, "Returned parcel cannot be null"); + Objects.requireNonNull(content, "Returned parcel content cannot be null"); + Objects.requireNonNull(delivery, "Returned parcel delivery cannot be null"); + + return this.action(ParcelTable.class, parcelDao -> + TransactionManager.callInTransaction(this.databaseManager.connectionSource(), () -> { + UpdateBuilder update = parcelDao.updateBuilder(); + update.updateColumnValue("sender", returned.sender()); + update.updateColumnValue("receiver", returned.receiver()); + update.updateColumnValue("entry_locker", returned.entryLocker()); + update.updateColumnValue("destination_locker", returned.destinationLocker()); + update.updateColumnValue("status", ParcelStatus.SENT); + update.where() + .eq("uuid", returned.uuid()) + .and() + .eq("status", ParcelStatus.COLLECTED); + + if (update.update() == 0) { + return false; + } + + this.databaseManager.getDao(ParcelContentTable.class) + .createOrUpdate(ParcelContentTable.from(content)); + this.databaseManager.getDao(DeliveryTable.class) + .createOrUpdate(DeliveryTable.from(delivery)); + this.databaseManager.getDao(CollectedParcelTable.class) + .deleteById(returned.uuid()); + return true; + })); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java b/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java new file mode 100644 index 00000000..46d98e3f --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/returns/task/ReturnWindowPurgeTask.java @@ -0,0 +1,85 @@ +package com.eternalcode.parcellockers.returns.task; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Deletes collected parcels whose return window expired: the parcel row, its content row and the + * collected_parcels row. Runs periodically; failures are logged and retried on the next run. + */ +public class ReturnWindowPurgeTask implements Runnable { + + private static final Logger LOGGER = Logger.getLogger(ReturnWindowPurgeTask.class.getName()); + + /** Extra slack past the window so an in-flight return at the expiry boundary cannot race the purge. */ + private static final Duration GRACE = Duration.ofMinutes(5); + + private final ParcelService parcelService; + private final CollectedParcelRepository collectedParcelRepository; + private final DeliveryManager deliveryManager; + private final PluginConfig config; + + public ReturnWindowPurgeTask( + ParcelService parcelService, + CollectedParcelRepository collectedParcelRepository, + DeliveryManager deliveryManager, + PluginConfig config + ) { + this.parcelService = parcelService; + this.collectedParcelRepository = collectedParcelRepository; + this.deliveryManager = deliveryManager; + this.config = config; + } + + @Override + public void run() { + Instant cutoff = Instant.now().minus(this.config.settings.parcelReturnWindow).minus(GRACE); + + this.collectedParcelRepository.findExpired(cutoff) + .thenAccept(expired -> expired.forEach(this::purge)) + .exceptionally(throwable -> { + LOGGER.log(Level.SEVERE, "Failed to query expired collected parcels", throwable); + return null; + }); + } + + private void purge(CollectedParcel collected) { + this.parcelService.get(collected.parcel()) + .thenCompose(optionalParcel -> { + if (optionalParcel.isPresent() && optionalParcel.get().status() == ParcelStatus.COLLECTED) { + // Delete the parcel (and content) first; the row is only removed once that + // succeeded so a failed delete is retried on the next run. + return this.parcelService.delete(collected.parcel()) + .thenCompose(deleted -> Boolean.TRUE.equals(deleted) + ? this.collectedParcelRepository.delete(collected.parcel()) + : CompletableFuture.completedFuture(false)) + .thenCompose(unused -> { + // Best-effort: clears a stray delivery row left behind by a failed + // delete at ParcelSendTask.deliver time; inert but leaks otherwise. + return this.deliveryManager.delete(collected.parcel()) + .exceptionally(throwable -> { + LOGGER.log(Level.WARNING, "Failed to delete stray delivery for purged parcel " + + collected.parcel(), throwable); + return false; + }); + }); + } + // Stray row: the parcel is gone or is a live shipment again — drop only the row. + return this.collectedParcelRepository.delete(collected.parcel()); + }) + .exceptionally(throwable -> { + LOGGER.log(Level.WARNING, "Failed to purge expired collected parcel " + collected.parcel() + + " (will retry next run)", throwable); + return false; + }); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java new file mode 100644 index 00000000..ef28c000 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/CollectedParcelRepositoryIntegrationTest.java @@ -0,0 +1,110 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import java.nio.file.Path; +import java.sql.SQLException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers(disabledWithoutDocker = true) +class CollectedParcelRepositoryIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + private CollectedParcelRepository repository() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.MYSQL; + config.settings.host = mySQLContainer.getHost(); + config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); + config.settings.databaseName = mySQLContainer.getDatabaseName(); + config.settings.user = mySQLContainer.getUsername(); + config.settings.password = mySQLContainer.getPassword(); + + DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); + databaseManager.connect(); + this.databaseManager = databaseManager; + + return new CollectedParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + } + + @Test + void savesAndFindsCollectedParcel() throws SQLException { + CollectedParcelRepository repository = this.repository(); + UUID parcel = UUID.randomUUID(); + // MySQL TIMESTAMP columns don't keep nanos; truncate so the round-trip compares equal. + Instant collectedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + this.await(repository.save(new CollectedParcel(parcel, collectedAt))); + + Optional found = this.await(repository.find(parcel)); + assertTrue(found.isPresent()); + assertEquals(collectedAt, found.get().collectedAt()); + } + + @Test + void findExpiredReturnsOnlyRowsAtOrBeforeCutoff() throws SQLException { + CollectedParcelRepository repository = this.repository(); + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant cutoff = now.minusSeconds(60); + + UUID expired = UUID.randomUUID(); + UUID atCutoff = UUID.randomUUID(); + UUID justAfterCutoff = UUID.randomUUID(); + UUID fresh = UUID.randomUUID(); + this.await(repository.save(new CollectedParcel(expired, now.minusSeconds(3600)))); + this.await(repository.save(new CollectedParcel(atCutoff, cutoff))); + // Sub-second offset: guards the temporal (not lexicographic) comparison of the + // string-persisted column. + this.await(repository.save(new CollectedParcel(justAfterCutoff, cutoff.plusMillis(500)))); + this.await(repository.save(new CollectedParcel(fresh, now))); + + List result = this.await(repository.findExpired(cutoff)); + List resultParcels = result.stream().map(CollectedParcel::parcel).toList(); + assertEquals(2, result.size()); + assertTrue(resultParcels.contains(expired)); + assertTrue(resultParcels.contains(atCutoff)); + } + + @Test + void deleteRemovesRow() throws SQLException { + CollectedParcelRepository repository = this.repository(); + UUID parcel = UUID.randomUUID(); + this.await(repository.save(new CollectedParcel(parcel, Instant.now().truncatedTo(ChronoUnit.SECONDS)))); + + assertTrue(this.await(repository.delete(parcel))); + assertFalse(this.await(repository.find(parcel)).isPresent()); + assertFalse(this.await(repository.delete(parcel))); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java index 4ee9ea80..4892af96 100644 --- a/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelRepositoryIntegrationTest.java @@ -1,6 +1,7 @@ package com.eternalcode.parcellockers.database; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import com.eternalcode.parcellockers.TestScheduler; @@ -64,6 +65,21 @@ void test() throws SQLException { assertTrue(parcel.isPresent()); assertEquals(uuid, parcel.get().uuid()); + // updateIfStatus refuses when the expected status no longer matches the row (e.g. a + // concurrent status change won the race) and leaves the row untouched. + Parcel staleEdit = new Parcel(uuid, sender, "renamed", "description", true, receiver, + ParcelSize.SMALL, entryLocker, destinationLocker, ParcelStatus.DELIVERED); + boolean staleApplied = this.await(parcelRepository.updateIfStatus(staleEdit, ParcelStatus.DELIVERED)); + assertFalse(staleApplied); + assertEquals("name", this.await(parcelRepository.findById(uuid)).orElseThrow().name()); + + // updateIfStatus applies when the expected status still matches. + Parcel freshEdit = new Parcel(uuid, sender, "renamed", "description", true, receiver, + ParcelSize.SMALL, entryLocker, destinationLocker, ParcelStatus.SENT); + boolean freshApplied = this.await(parcelRepository.updateIfStatus(freshEdit, ParcelStatus.SENT)); + assertTrue(freshApplied); + assertEquals("renamed", this.await(parcelRepository.findById(uuid)).orElseThrow().name()); + List byReceiver = this.await(parcelRepository.findByReceiver(receiver)); assertEquals(1, byReceiver.size()); assertEquals(uuid, byReceiver.getFirst().uuid()); diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java new file mode 100644 index 00000000..150ebe08 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnCommitRepositoryIntegrationTest.java @@ -0,0 +1,189 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepository; +import com.eternalcode.parcellockers.content.repository.ParcelContentRepositoryOrmLite; +import com.eternalcode.parcellockers.delivery.Delivery; +import com.eternalcode.parcellockers.delivery.repository.DeliveryRepository; +import com.eternalcode.parcellockers.delivery.repository.DeliveryRepositoryOrmLite; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.CollectedParcel; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepositoryOrmLite; +import java.io.File; +import java.lang.reflect.Field; +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.UnsafeValues; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ParcelReturnCommitRepositoryIntegrationTest extends IntegrationTestSpec { + + private DatabaseManager databaseManager; + private TestScheduler scheduler; + private ParcelRepository parcelRepository; + private ParcelContentRepository contentRepository; + private DeliveryRepository deliveryRepository; + private CollectedParcelRepository collectedRepository; + private ParcelReturnRepository returnRepository; + + @BeforeEach + void setUp() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.H2; + + File dataFolder = new File("build/tmp/parcel-return-" + UUID.randomUUID()); + this.databaseManager = new DatabaseManager( + config, Logger.getLogger("ParcelLockers"), dataFolder); + this.databaseManager.connect(); + this.scheduler = new TestScheduler(); + this.parcelRepository = new ParcelRepositoryOrmLite(this.databaseManager, this.scheduler); + this.contentRepository = new ParcelContentRepositoryOrmLite(this.databaseManager, this.scheduler); + this.deliveryRepository = new DeliveryRepositoryOrmLite(this.databaseManager, this.scheduler); + this.collectedRepository = new CollectedParcelRepositoryOrmLite(this.databaseManager, this.scheduler); + this.returnRepository = new ParcelReturnRepositoryOrmLite(this.databaseManager, this.scheduler); + } + + @Test + void concurrentReturnsKeepTheWinningContentAndDelivery() throws ReflectiveOperationException { + Parcel collected = collectedParcel(); + ItemStack originalStack = stack((byte) 1); + ItemStack firstStack = stack((byte) 2); + ItemStack secondStack = stack((byte) 3); + + installDeserializer(Map.of( + (byte) 1, originalStack, + (byte) 2, firstStack, + (byte) 3, secondStack + )); + this.await(this.parcelRepository.save(collected)); + this.await(this.contentRepository.save(content(collected.uuid(), originalStack))); + this.await(this.collectedRepository.save(new CollectedParcel( + collected.uuid(), Instant.parse("2026-07-14T10:00:00Z")))); + + Parcel returned = returned(collected); + Instant firstDelivery = Instant.parse("2026-07-14T11:00:00Z"); + Instant secondDelivery = Instant.parse("2026-07-14T12:00:00Z"); + + CompletableFuture first = this.returnRepository.commit( + returned, content(collected.uuid(), firstStack), + new Delivery(collected.uuid(), firstDelivery)); + CompletableFuture second = this.returnRepository.commit( + returned, content(collected.uuid(), secondStack), + new Delivery(collected.uuid(), secondDelivery)); + + boolean firstWon = this.await(first); + boolean secondWon = this.await(second); + + assertNotEquals(firstWon, secondWon); + ItemStack expectedStack = firstWon ? firstStack : secondStack; + Instant expectedDelivery = firstWon ? firstDelivery : secondDelivery; + assertSame(expectedStack, this.await(this.contentRepository.find(collected.uuid())) + .orElseThrow().items().getFirst()); + assertEquals(expectedDelivery, this.await(this.deliveryRepository.find(collected.uuid())) + .orElseThrow().deliveryTimestamp()); + assertEquals(ParcelStatus.SENT, this.await(this.parcelRepository.findById(collected.uuid())) + .orElseThrow().status()); + assertTrue(this.await(this.collectedRepository.find(collected.uuid())).isEmpty()); + } + + @Test + void failedClaimDoesNotChangeContentOrDelivery() throws ReflectiveOperationException { + Parcel sent = returned(collectedParcel()); + Instant existingDelivery = Instant.parse("2026-07-14T11:00:00Z"); + ItemStack existingStack = stack((byte) 4); + ItemStack losingStack = stack((byte) 5); + + installDeserializer(Map.of( + (byte) 4, existingStack, + (byte) 5, losingStack + )); + this.await(this.parcelRepository.save(sent)); + this.await(this.contentRepository.save(content(sent.uuid(), existingStack))); + this.await(this.deliveryRepository.save(new Delivery(sent.uuid(), existingDelivery))); + + boolean committed = this.await(this.returnRepository.commit( + sent, content(sent.uuid(), losingStack), + new Delivery(sent.uuid(), Instant.parse("2026-07-14T12:00:00Z")))); + + assertFalse(committed); + assertSame(existingStack, this.await(this.contentRepository.find(sent.uuid())) + .orElseThrow().items().getFirst()); + assertEquals(existingDelivery, this.await(this.deliveryRepository.find(sent.uuid())) + .orElseThrow().deliveryTimestamp()); + } + + private static Parcel collectedParcel() { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "parcel", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), + ParcelStatus.COLLECTED); + } + + private static Parcel returned(Parcel collected) { + return new Parcel(collected.uuid(), collected.receiver(), collected.name(), + collected.description(), collected.priority(), collected.sender(), collected.size(), + collected.destinationLocker(), collected.entryLocker(), ParcelStatus.SENT); + } + + private static ParcelContent content(UUID parcel, ItemStack item) { + return new ParcelContent(parcel, List.of(item)); + } + + private static ItemStack stack(byte marker) { + ItemStack item = mock(ItemStack.class); + when(item.isEmpty()).thenReturn(false); + when(item.serializeAsBytes()).thenReturn(new byte[] { marker }); + return item; + } + + private static void installDeserializer(Map items) + throws ReflectiveOperationException { + UnsafeValues unsafe = mock(UnsafeValues.class); + when(unsafe.deserializeItem(any(byte[].class))).thenAnswer(invocation -> { + byte[] serialized = invocation.getArgument(0); + return items.get(serialized[0]); + }); + Server server = mock(Server.class); + when(server.getUnsafe()).thenReturn(unsafe); + Field serverField = Bukkit.class.getDeclaredField("server"); + serverField.setAccessible(true); + serverField.set(null, server); + } + + @AfterEach + void tearDown() { + if (this.scheduler != null) { + this.scheduler.shutdown(); + } + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java new file mode 100644 index 00000000..dc5dc7e5 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/database/ParcelReturnRepositoryIntegrationTest.java @@ -0,0 +1,111 @@ +package com.eternalcode.parcellockers.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eternalcode.parcellockers.TestScheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepository; +import com.eternalcode.parcellockers.parcel.repository.ParcelRepositoryOrmLite; +import com.eternalcode.parcellockers.shared.Page; +import com.eternalcode.parcellockers.shared.PageResult; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers(disabledWithoutDocker = true) +class ParcelReturnRepositoryIntegrationTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer mySQLContainer = new MySQLContainer<>(DockerImageName.parse("mysql:latest")); + + @TempDir + private Path tempDir; + + private DatabaseManager databaseManager; + + private ParcelRepository repository() throws SQLException { + PluginConfig config = new PluginConfig(); + config.settings.databaseType = DatabaseType.MYSQL; + config.settings.host = mySQLContainer.getHost(); + config.settings.port = String.valueOf(mySQLContainer.getFirstMappedPort()); + config.settings.databaseName = mySQLContainer.getDatabaseName(); + config.settings.user = mySQLContainer.getUsername(); + config.settings.password = mySQLContainer.getPassword(); + + DatabaseManager databaseManager = new DatabaseManager(config, Logger.getLogger("ParcelLockers"), this.tempDir.toFile()); + databaseManager.connect(); + this.databaseManager = databaseManager; + + return new ParcelRepositoryOrmLite(databaseManager, new TestScheduler()); + } + + private static Parcel parcel(UUID receiver, UUID destinationLocker, ParcelStatus status) { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "p", "d", false, + receiver, ParcelSize.SMALL, UUID.randomUUID(), destinationLocker, status); + } + + @Test + void markCollectedFlipsOnlyDeliveredParcels() throws SQLException { + ParcelRepository repository = this.repository(); + Parcel delivered = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.DELIVERED); + this.await(repository.save(delivered)); + + assertTrue(this.await(repository.markCollected(delivered.uuid()))); + assertEquals(ParcelStatus.COLLECTED, this.await(repository.findById(delivered.uuid())).orElseThrow().status()); + + // Second collect attempt must not report success — this is the double-collect guard. + assertFalse(this.await(repository.markCollected(delivered.uuid()))); + + Parcel sent = parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.SENT); + this.await(repository.save(sent)); + assertFalse(this.await(repository.markCollected(sent.uuid()))); + } + + @Test + void findReturnableReturnsOnlyCollectedParcelsOfReceiver() throws SQLException { + ParcelRepository repository = this.repository(); + UUID receiver = UUID.randomUUID(); + + this.await(repository.save(parcel(receiver, UUID.randomUUID(), ParcelStatus.COLLECTED))); + this.await(repository.save(parcel(receiver, UUID.randomUUID(), ParcelStatus.COLLECTED))); + this.await(repository.save(parcel(receiver, UUID.randomUUID(), ParcelStatus.DELIVERED))); + this.await(repository.save(parcel(UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED))); + + PageResult page = this.await(repository.findReturnable(receiver, new Page(0, 10))); + assertEquals(2, page.items().size()); + assertTrue(page.items().stream().allMatch(item -> item.status() == ParcelStatus.COLLECTED)); + assertTrue(page.items().stream().allMatch(item -> item.receiver().equals(receiver))); + } + + @Test + void collectedParcelsDoNotCountTowardsLockerFullness() throws SQLException { + ParcelRepository repository = this.repository(); + UUID locker = UUID.randomUUID(); + + this.await(repository.save(parcel(UUID.randomUUID(), locker, ParcelStatus.SENT))); + this.await(repository.save(parcel(UUID.randomUUID(), locker, ParcelStatus.DELIVERED))); + this.await(repository.save(parcel(UUID.randomUUID(), locker, ParcelStatus.COLLECTED))); + + assertEquals(2, this.await(repository.countParcelsByDestinationLocker(locker))); + } + + @AfterEach + void tearDown() { + if (this.databaseManager != null) { + this.databaseManager.disconnect(); + } + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java new file mode 100644 index 00000000..0deb5287 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnDepositGuiTest.java @@ -0,0 +1,101 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import dev.triumphteam.gui.TriumphGui; +import dev.triumphteam.gui.components.GuiAction; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.StorageGui; +import java.lang.reflect.Field; +import java.util.List; +import java.util.UUID; +import java.util.function.BiFunction; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ReturnDepositGuiTest { + + private Field serverField; + private Field triumphPluginField; + private Object previousServer; + private Object previousTriumphPlugin; + + @BeforeEach + void installGuiEnvironment() throws ReflectiveOperationException { + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + this.serverField = Bukkit.class.getDeclaredField("server"); + this.serverField.setAccessible(true); + this.previousServer = this.serverField.get(null); + this.serverField.set(null, server); + + this.triumphPluginField = TriumphGui.class.getDeclaredField("PLUGIN"); + this.triumphPluginField.setAccessible(true); + this.previousTriumphPlugin = this.triumphPluginField.get(null); + TriumphGui.init(mock(Plugin.class)); + } + + @AfterEach + void restoreGuiEnvironment() throws ReflectiveOperationException { + this.serverField.set(null, this.previousServer); + this.triumphPluginField.set(null, this.previousTriumphPlugin); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void emptyConfirmationUsesNormalReturnValidationFlow() throws ReflectiveOperationException { + Scheduler scheduler = mock(Scheduler.class); + GuiSettings settings = mock(GuiSettings.class); + settings.parcelReturnDepositGuiTitle = "Return items"; + settings.mainGuiBackgroundItem = mock(ConfigItem.class); + settings.confirmReturnItem = mock(ConfigItem.class); + MiniMessage miniMessage = mock(MiniMessage.class); + when(miniMessage.deserialize("Return items")).thenReturn(Component.empty()); + GuiManager guiManager = mock(GuiManager.class); + Player player = mock(Player.class); + Parcel parcel = new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "parcel", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED + ); + + StorageGui gui = mock(StorageGui.class); + when(gui.getRows()).thenReturn(2); + Inventory inventory = mock(Inventory.class); + when(inventory.getContents()).thenReturn(new ItemStack[18]); + when(gui.getInventory()).thenReturn(inventory); + when(settings.mainGuiBackgroundItem.toGuiItem(any())).thenReturn(mock(GuiItem.class)); + ArgumentCaptor> confirmAction = ArgumentCaptor.forClass(GuiAction.class); + when(settings.confirmReturnItem.toGuiItem(confirmAction.capture())).thenReturn(mock(GuiItem.class)); + BiFunction guiFactory = (title, rows) -> gui; + + new ReturnDepositGui(scheduler, settings, miniMessage, guiManager, parcel, guiFactory).show(player); + InventoryClickEvent click = mock(InventoryClickEvent.class); + confirmAction.getValue().execute(click); + + verify(click).setCancelled(true); + verify(gui).close(player); + verify(guiManager).returnParcel(player, parcel, List.of()); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java new file mode 100644 index 00000000..42003650 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/gui/implementation/locker/ReturnGuiTest.java @@ -0,0 +1,107 @@ +package com.eternalcode.parcellockers.gui.implementation.locker; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig.GuiSettings; +import com.eternalcode.parcellockers.configuration.serializable.ConfigItem; +import com.eternalcode.parcellockers.gui.GuiManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.shared.PageResult; +import dev.triumphteam.gui.TriumphGui; +import dev.triumphteam.gui.guis.GuiItem; +import dev.triumphteam.gui.guis.PaginatedGui; +import java.lang.reflect.Field; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ReturnGuiTest { + + private Field serverField; + private Field triumphPluginField; + private Object previousServer; + private Object previousTriumphPlugin; + + @BeforeEach + void installGuiEnvironment() throws ReflectiveOperationException { + Server server = mock(Server.class); + when(server.getPluginManager()).thenReturn(mock(PluginManager.class)); + this.serverField = Bukkit.class.getDeclaredField("server"); + this.serverField.setAccessible(true); + this.previousServer = this.serverField.get(null); + this.serverField.set(null, server); + + this.triumphPluginField = TriumphGui.class.getDeclaredField("PLUGIN"); + this.triumphPluginField.setAccessible(true); + this.previousTriumphPlugin = this.triumphPluginField.get(null); + TriumphGui.init(mock(Plugin.class)); + } + + @AfterEach + void restoreGuiEnvironment() throws ReflectiveOperationException { + this.serverField.set(null, this.previousServer); + this.triumphPluginField.set(null, this.previousTriumphPlugin); + } + + @Test + void emptyResultMutatesGuiOnlyInsideScheduledMainThreadCallback() throws ReflectiveOperationException { + GuiSettings settings = mock(GuiSettings.class); + settings.parcelReturnGuiTitle = "Return parcels"; + settings.parcelReturnRowItem = mock(ConfigItem.class); + settings.closeItem = mock(ConfigItem.class); + settings.cornerItem = mock(ConfigItem.class); + settings.mainGuiBackgroundItem = mock(ConfigItem.class); + settings.noReturnableParcelsItem = mock(ConfigItem.class); + GuiItem emptyItem = mock(GuiItem.class); + when(settings.noReturnableParcelsItem.toGuiItem()).thenReturn(emptyItem); + + Scheduler scheduler = mock(Scheduler.class); + GuiManager guiManager = mock(GuiManager.class); + MiniMessage miniMessage = mock(MiniMessage.class); + NoticeService noticeService = mock(NoticeService.class); + PaginatedGui gui = mock(PaginatedGui.class); + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerId); + when(miniMessage.deserialize("Return parcels")).thenReturn(Component.empty()); + CompletableFuture> result = new CompletableFuture<>(); + when(guiManager.getReturnableParcels(any(), any())).thenReturn(result); + + Function guiFactory = ignored -> gui; + ReturnGui returnGui = new ReturnGui( + settings, scheduler, guiManager, miniMessage, guiFactory); + returnGui.show(player); + clearInvocations(gui, scheduler); + + result.complete(PageResult.empty()); + + verify(gui, never()).setItem(anyInt(), any(GuiItem.class)); + verify(gui, never()).open(player); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).run(callback.capture()); + + callback.getValue().run(); + + verify(gui).setItem(22, emptyItem); + verify(gui).open(player); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java index 586e18c2..aa33f5a2 100644 --- a/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java +++ b/src/test/java/com/eternalcode/parcellockers/parcel/service/AdminParcelServiceTest.java @@ -2,13 +2,34 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import com.eternalcode.parcellockers.parcel.Parcel; import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; import java.time.Duration; import java.time.Instant; +import java.util.UUID; import org.junit.jupiter.api.Test; class AdminParcelServiceTest { + private static Parcel collectedParcel() { + return new Parcel(UUID.randomUUID(), UUID.randomUUID(), "name", "description", false, + UUID.randomUUID(), ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED); + } + + @Test + void changeStatusRefusesCollectedParcelWithoutTouchingAnyCollaborator() { + // No collaborator is a usable mock/instance here on purpose: the COLLECTED guard must + // short-circuit before dereferencing any of them, so a regression that removes the guard + // and reaches a collaborator call would fail this test with an NPE instead of passing. + AdminParcelService service = new AdminParcelService(null, null, null, null, null, null); + + Parcel collected = collectedParcel(); + EditResult result = service.changeStatus(collected, ParcelStatus.SENT).join(); + + assertEquals(EditResult.Status.PARCEL_COLLECTED, result.status()); + } + @Test void capacityMatchesContentGuiUsableSlots() { assertEquals(9, AdminParcelService.capacity(ParcelSize.SMALL)); diff --git a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java index 80dace4d..29014a27 100644 --- a/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java +++ b/src/test/java/com/eternalcode/parcellockers/parcel/task/ParcelSendTaskTest.java @@ -54,4 +54,14 @@ void deliversWhenSentWithNoDeliveryRow() { Parcel sent = parcel(ParcelStatus.SENT); assertEquals(Decision.DELIVER, ParcelSendTask.decide(Optional.of(sent), Optional.empty(), now)); } + + @Test + void abortsWhenAlreadyCollected() { + // A COLLECTED parcel sits in the receiver's return window; a stale delivery row + // must not re-deliver it, or the items could be collected twice. + Parcel collected = parcel(ParcelStatus.COLLECTED); + Delivery due = new Delivery(collected.uuid(), Instant.parse("2026-06-21T11:00:00Z")); + assertEquals(Decision.ABORT, ParcelSendTask.decide( + Optional.of(collected), Optional.of(due), Instant.parse("2026-06-21T12:00:00Z"))); + } } diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java new file mode 100644 index 00000000..1ba75c21 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnServiceTest.java @@ -0,0 +1,250 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_SELF; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.eternalcode.commons.bukkit.ItemUtil; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.Notice; +import com.eternalcode.multification.notice.NoticeBroadcast; +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import com.eternalcode.parcellockers.content.ParcelContent; +import com.eternalcode.parcellockers.content.ParcelContentManager; +import com.eternalcode.parcellockers.delivery.DeliveryManager; +import com.eternalcode.parcellockers.locker.Locker; +import com.eternalcode.parcellockers.locker.LockerManager; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.eternalcode.parcellockers.parcel.Parcel; +import com.eternalcode.parcellockers.parcel.ParcelSize; +import com.eternalcode.parcellockers.parcel.ParcelStatus; +import com.eternalcode.parcellockers.parcel.service.ParcelService; +import com.eternalcode.parcellockers.returns.repository.CollectedParcelRepository; +import com.eternalcode.parcellockers.returns.repository.ParcelReturnRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import net.milkbowl.vault.economy.Economy; +import org.bukkit.Material; +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.ArgumentCaptor; +import org.mockito.MockedStatic; + +class ParcelReturnServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-02T12:00:00Z"); + private static final Duration WINDOW = Duration.ofDays(7); + + @Test + void withinWindowJustAfterCollection() { + CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(Duration.ofHours(1))); + assertTrue(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); + } + + @Test + void outsideWindowAfterExpiry() { + CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(Duration.ofDays(8))); + assertFalse(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); + } + + @Test + void exactExpiryInstantIsOutsideWindow() { + CollectedParcel collected = new CollectedParcel(UUID.randomUUID(), NOW.minus(WINDOW)); + assertFalse(ParcelReturnService.isWithinReturnWindow(collected, WINDOW, NOW)); + } + + @Test + void waitsForDurableCommitBeforeSchedulingOrReportingSuccess() { + Fixture fixture = new Fixture(); + fixture.validReturn(); + CompletableFuture commit = new CompletableFuture<>(); + when(fixture.returnRepository.commit(any(), any(), any())).thenReturn(commit); + + CompletableFuture result = fixture.service.returnParcel( + fixture.player, fixture.parcel, fixture.deposited); + + assertFalse(result.isDone()); + verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); + verify(fixture.noticeService, never()).player(eq(fixture.playerId), any()); + + commit.complete(true); + result.join(); + + verify(fixture.scheduler).runLaterAsync(any(Runnable.class), any(Duration.class)); + verify(fixture.noticeService).player(eq(fixture.playerId), any()); + } + + @Test + void rejectsReturnWhenOriginalEntryLockerNoLongerExists() { + Fixture fixture = new Fixture(); + fixture.validReturn(); + when(fixture.lockerManager.get(fixture.parcel.entryLocker())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited).join(); + + verify(fixture.lockerManager, never()).isLockerFull(any()); + verify(fixture.returnRepository, never()).commit(any(), any(), any()); + verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + } + + @Test + void capturesFeeBypassPermissionBeforeRepositoryContinuation() { + Fixture fixture = new Fixture(); + CompletableFuture> pendingParcel = new CompletableFuture<>(); + when(fixture.parcelService.get(fixture.parcel.uuid())).thenReturn(pendingParcel); + + fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited); + + verify(fixture.player).hasPermission("parcellockers.fee.bypass"); + } + + @Test + void appliesDefaultPrefixToReturnedParcelName() { + Fixture fixture = new Fixture(); + assertEquals("[Refund] parcel", fixture.returnedParcelName()); + } + + @Test + void appliesCustomReturnedParcelNameFormat() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned: {NAME} / {NAME}"; + assertEquals("Returned: parcel / parcel", fixture.returnedParcelName()); + } + + @Test + void usesLiteralReturnedParcelNameWhenFormatHasNoPlaceholder() { + Fixture fixture = new Fixture(); + fixture.config.settings.parcelReturnNameFormat = "Returned parcel"; + assertEquals("Returned parcel", fixture.returnedParcelName()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + void reportsAllMismatchReasonsAndStopsReturnProcessing() { + Fixture fixture = new Fixture(); + fixture.validReturn(); + ParcelReturnValidationResult mismatch = new ParcelReturnValidationResult(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.unexpected(Material.DIRT, 2) + )); + when(fixture.validator.validate(fixture.deposited, fixture.deposited)).thenReturn(mismatch); + when(fixture.mismatchFormatter.format(mismatch)).thenReturn("firstsecond"); + Notice mismatchNotice = mock(Notice.class); + when(fixture.mismatchFormatter.notice(mismatch)).thenReturn(mismatchNotice); + NoticeBroadcast broadcast = mock(NoticeBroadcast.class, RETURNS_SELF); + when(fixture.noticeService.create()).thenReturn(broadcast); + + fixture.service.returnParcel(fixture.player, fixture.parcel, fixture.deposited).join(); + + verify(broadcast).notice(mismatchNotice); + verify(broadcast).placeholder("{MISMATCHES}", "firstsecond"); + verify(broadcast).send(); + ArgumentCaptor giveBack = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).run(giveBack.capture()); + try (MockedStatic itemUtil = mockStatic(ItemUtil.class)) { + giveBack.getValue().run(); + itemUtil.verify(() -> ItemUtil.giveItem(fixture.player, fixture.item)); + } + + verify(fixture.returnRepository, never()).commit(any(), any(), any()); + verify(fixture.economy, never()).withdrawPlayer(any(Player.class), anyDouble()); + verify(fixture.scheduler, never()).runLaterAsync(any(Runnable.class), any(Duration.class)); + } + + private static final class Fixture { + + private final UUID playerId = UUID.randomUUID(); + private final Player player = mock(Player.class); + private final ParcelService parcelService = mock(ParcelService.class); + private final ParcelContentManager contentManager = mock(ParcelContentManager.class); + private final CollectedParcelRepository collectedRepository = mock(CollectedParcelRepository.class); + private final DeliveryManager deliveryManager = mock(DeliveryManager.class); + private final LockerManager lockerManager = mock(LockerManager.class); + private final ParcelReturnValidator validator = mock(ParcelReturnValidator.class); + private final ReturnMismatchFormatter mismatchFormatter = mock(ReturnMismatchFormatter.class); + private final ParcelReturnRepository returnRepository = mock(ParcelReturnRepository.class); + private final Scheduler scheduler = mock(Scheduler.class); + private final NoticeService noticeService = mock(NoticeService.class); + private final Economy economy = mock(Economy.class); + private final Server server = mock(Server.class); + private final PluginManager pluginManager = mock(PluginManager.class); + private final PluginConfig config = new PluginConfig(); + private final ItemStack item = mock(ItemStack.class); + private final List deposited = List.of(this.item); + private final Parcel parcel = new Parcel( + UUID.randomUUID(), UUID.randomUUID(), "parcel", "description", false, + this.playerId, ParcelSize.SMALL, UUID.randomUUID(), UUID.randomUUID(), ParcelStatus.COLLECTED + ); + private final ParcelReturnService service; + + private Fixture() { + this.config.settings.smallParcelReturnFee = 0; + when(this.player.getUniqueId()).thenReturn(this.playerId); + when(this.player.getName()).thenReturn("Player"); + when(this.item.clone()).thenReturn(this.item); + when(this.server.getPluginManager()).thenReturn(this.pluginManager); + this.service = new ParcelReturnService( + this.parcelService, + this.contentManager, + this.collectedRepository, + this.deliveryManager, + this.lockerManager, + this.validator, + this.mismatchFormatter, + this.returnRepository, + this.scheduler, + this.config, + this.noticeService, + this.economy, + this.server + ); + } + + private void validReturn() { + ParcelContent content = new ParcelContent(this.parcel.uuid(), this.deposited); + when(this.parcelService.get(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(this.parcel))); + when(this.collectedRepository.find(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of( + new CollectedParcel(this.parcel.uuid(), Instant.now())))); + when(this.contentManager.get(this.parcel.uuid())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(content))); + when(this.validator.validate(this.deposited, this.deposited)) + .thenReturn(new ParcelReturnValidationResult(List.of())); + when(this.lockerManager.get(this.parcel.entryLocker())) + .thenReturn(CompletableFuture.completedFuture(Optional.of( + new Locker(this.parcel.entryLocker(), "Entry", null)))); + when(this.lockerManager.isLockerFull(this.parcel.entryLocker())) + .thenReturn(CompletableFuture.completedFuture(false)); + } + + private String returnedParcelName() { + this.validReturn(); + when(this.returnRepository.commit(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + + this.service.returnParcel(this.player, this.parcel, this.deposited).join(); + + ArgumentCaptor returnedParcel = ArgumentCaptor.forClass(Parcel.class); + verify(this.returnRepository).commit(returnedParcel.capture(), any(), any()); + return returnedParcel.getValue().name(); + } + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java new file mode 100644 index 00000000..29bde659 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ParcelReturnValidatorTest.java @@ -0,0 +1,211 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.util.EnumSet; +import java.util.List; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +class ParcelReturnValidatorTest { + + private static final ReturnItemComparator BY_MATERIAL = (expected, deposited) -> + expected.getType() == deposited.getType() + ? EnumSet.noneOf(ReturnItemDifference.class) + : EnumSet.of(ReturnItemDifference.MATERIAL); + + private final ParcelReturnValidator validator = new ParcelReturnValidator(BY_MATERIAL); + + private static ItemStack item(Material type, int amount) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(type); + when(item.getAmount()).thenReturn(amount); + return item; + } + + private static ItemStack damagedItem(Material type, int amount, int damage) { + ItemStack item = item(type, amount); + ItemMeta meta = mock(ItemMeta.class, withSettings().extraInterfaces(Damageable.class)); + when(((Damageable) meta).getDamage()).thenReturn(damage); + when(item.getItemMeta()).thenReturn(meta); + return item; + } + + @Test + void exactSameStacksMatch() { + List expected = List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 64)); + List deposited = List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 64)); + + assertTrue(this.validator.validate(deposited, expected).matches()); + assertTrue(this.validator.matches(deposited, expected)); + } + + @Test + void splitAndMergedStacksStillMatch() { + assertTrue(this.validator.matches( + List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)), + List.of(item(Material.OAK_LOG, 64)) + )); + assertTrue(this.validator.matches( + List.of(item(Material.OAK_LOG, 64)), + List.of(item(Material.OAK_LOG, 30), item(Material.OAK_LOG, 34)) + )); + } + + @Test + void reportsInsufficientAmountAndUnexpectedMaterialWithValues() { + ParcelReturnValidationResult result = this.validator.validate( + List.of(item(Material.DIAMOND, 4), item(Material.DIRT, 2)), + List.of(item(Material.DIAMOND, 5)) + ); + + assertFalse(result.matches()); + assertEquals(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.unexpected(Material.DIRT, 2) + ), result.mismatches()); + } + + @Test + void reportsExcessAmountForExpectedMaterial() { + ParcelReturnValidationResult result = this.validator.validate( + List.of(item(Material.DIAMOND, 6)), + List.of(item(Material.DIAMOND, 5)) + ); + + assertEquals(List.of(ReturnItemMismatch.excess(Material.DIAMOND, 5, 6)), result.mismatches()); + } + + @Test + void emptyDepositReportsEveryMissingMaterialInExpectedOrder() { + ParcelReturnValidationResult result = this.validator.validate( + List.of(), + List.of(item(Material.DIAMOND, 5), item(Material.OAK_LOG, 12)) + ); + + assertEquals(List.of( + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 0), + ReturnItemMismatch.insufficient(Material.OAK_LOG, 12, 0) + ), result.mismatches()); + } + + @Test + void reportsAllAttributeDifferencesForTheAffectedItem() { + ItemStack expected = damagedItem(Material.DIAMOND_SWORD, 1, 4); + ItemStack deposited = damagedItem(Material.DIAMOND_SWORD, 1, 27); + ReturnItemComparator attributes = (left, right) -> EnumSet.of( + ReturnItemDifference.DURABILITY, + ReturnItemDifference.ENCHANTMENTS + ); + + ParcelReturnValidationResult result = new ParcelReturnValidator(attributes) + .validate(List.of(deposited), List.of(expected)); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.DURABILITY, expected, deposited), + ReturnItemMismatch.attribute(ReturnMismatchType.ENCHANTMENTS, expected, deposited) + ), result.mismatches()); + } + + @Test + void consumesExactVariantBeforeDiagnosingClosestVariant() { + ItemStack expectedNamed = item(Material.DIAMOND_SWORD, 1); + ItemStack expectedEnchanted = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedExact = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedChangedName = item(Material.DIAMOND_SWORD, 1); + + ReturnItemComparator variants = (expected, deposited) -> { + if (expected == expectedEnchanted && deposited == depositedExact) { + return EnumSet.noneOf(ReturnItemDifference.class); + } + if (expected == expectedNamed && deposited == depositedChangedName) { + return EnumSet.of(ReturnItemDifference.ITEM_NAME); + } + return EnumSet.of(ReturnItemDifference.ITEM_NAME, ReturnItemDifference.ENCHANTMENTS); + }; + + ParcelReturnValidationResult result = new ParcelReturnValidator(variants).validate( + List.of(depositedExact, depositedChangedName), + List.of(expectedNamed, expectedEnchanted) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.ITEM_NAME, expectedNamed, depositedChangedName) + ), result.mismatches()); + } + + @Test + void choosesThePairWithTheFewestDifferences() { + ItemStack expectedNamed = item(Material.DIAMOND_SWORD, 1); + ItemStack expectedEnchanted = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedEnchanted = item(Material.DIAMOND_SWORD, 1); + ItemStack depositedNamed = item(Material.DIAMOND_SWORD, 1); + + ReturnItemComparator variants = (expected, deposited) -> { + if (expected == expectedNamed && deposited == depositedNamed) { + return EnumSet.of(ReturnItemDifference.ITEM_NAME); + } + if (expected == expectedEnchanted && deposited == depositedEnchanted) { + return EnumSet.of(ReturnItemDifference.ENCHANTMENTS); + } + return EnumSet.of(ReturnItemDifference.ITEM_NAME, ReturnItemDifference.ENCHANTMENTS); + }; + + ParcelReturnValidationResult result = new ParcelReturnValidator(variants).validate( + List.of(depositedEnchanted, depositedNamed), + List.of(expectedNamed, expectedEnchanted) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.ITEM_NAME, expectedNamed, depositedNamed), + ReturnItemMismatch.attribute(ReturnMismatchType.ENCHANTMENTS, expectedEnchanted, depositedEnchanted) + ), result.mismatches()); + } + + @Test + void aggregatesDuplicateAttributeReasons() { + ItemStack firstExpected = item(Material.DIAMOND_SWORD, 1); + ItemStack secondExpected = item(Material.DIAMOND_SWORD, 1); + ItemStack firstDeposited = item(Material.DIAMOND_SWORD, 1); + ItemStack secondDeposited = item(Material.DIAMOND_SWORD, 1); + ReturnItemComparator enchantments = (expected, deposited) -> + EnumSet.of(ReturnItemDifference.ENCHANTMENTS); + + ParcelReturnValidationResult result = new ParcelReturnValidator(enchantments).validate( + List.of(firstDeposited, secondDeposited), + List.of(firstExpected, secondExpected) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.ENCHANTMENTS, firstExpected, firstDeposited) + ), result.mismatches()); + } + + @Test + void resolvesEqualDifferenceCountsInInputOrder() { + ItemStack firstExpected = damagedItem(Material.DIAMOND_SWORD, 1, 1); + ItemStack secondExpected = damagedItem(Material.DIAMOND_SWORD, 1, 2); + ItemStack firstDeposited = damagedItem(Material.DIAMOND_SWORD, 1, 10); + ItemStack secondDeposited = damagedItem(Material.DIAMOND_SWORD, 1, 20); + ReturnItemComparator durability = (expected, deposited) -> + EnumSet.of(ReturnItemDifference.DURABILITY); + + ParcelReturnValidationResult result = new ParcelReturnValidator(durability).validate( + List.of(firstDeposited, secondDeposited), + List.of(firstExpected, secondExpected) + ); + + assertEquals(List.of( + ReturnItemMismatch.attribute(ReturnMismatchType.DURABILITY, firstExpected, firstDeposited), + ReturnItemMismatch.attribute(ReturnMismatchType.DURABILITY, secondExpected, secondDeposited) + ), result.mismatches()); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java new file mode 100644 index 00000000..2f7bde35 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnItemEquivalenceTest.java @@ -0,0 +1,187 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import com.eternalcode.parcellockers.configuration.implementation.PluginConfig; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +class ReturnItemEquivalenceTest { + + private static PluginConfig.ReturnChecks checks(boolean durability, boolean name, boolean enchants, boolean lore, boolean nbt) { + PluginConfig.ReturnChecks checks = new PluginConfig.ReturnChecks(); + checks.checkDurability = durability; + checks.checkItemName = name; + checks.checkEnchantments = enchants; + checks.checkLore = lore; + checks.checkNbt = nbt; + return checks; + } + + private static ItemStack item(Material type) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(type); + return item; + } + + private static ItemMeta damagedMeta(int damage) { + ItemMeta meta = mock(ItemMeta.class, withSettings().extraInterfaces(Damageable.class)); + when(((Damageable) meta).getDamage()).thenReturn(damage); + when(meta.getEnchants()).thenReturn(Map.of()); + return meta; + } + + @Test + void reportsEveryEnabledExplicitDifferenceWithoutDuplicatingNbt() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + ItemMeta expectedMeta = damagedMeta(4); + ItemMeta actualMeta = damagedMeta(27); + when(expectedMeta.getEnchants()).thenReturn(enchantsOf("sharpness", 3)); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + ItemStack normalizedExpected = normalizedClone(expected); + ItemStack normalizedActual = normalizedClone(actual); + when(normalizedExpected.isSimilar(normalizedActual)).thenReturn(true); + + ReturnItemEquivalence equivalence = new ReturnItemEquivalence(checks(true, true, true, true, true)); + + assertEquals( + EnumSet.of(ReturnItemDifference.DURABILITY, ReturnItemDifference.ENCHANTMENTS), + equivalence.differences(expected, actual) + ); + assertFalse(equivalence.test(expected, actual)); + } + + @Test + void reportsResidualNbtOnlyWhenEnabled() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + ItemMeta expectedMeta = damagedMeta(0); + ItemMeta actualMeta = damagedMeta(0); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + ItemStack normalizedExpected = normalizedClone(expected); + ItemStack normalizedActual = normalizedClone(actual); + when(normalizedExpected.isSimilar(normalizedActual)).thenReturn(false); + + assertEquals( + EnumSet.of(ReturnItemDifference.NBT), + new ReturnItemEquivalence(checks(false, false, false, false, true)).differences(expected, actual) + ); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + private static ItemStack normalizedClone(ItemStack original) { + ItemStack clone = item(original.getType()); + ItemMeta meta = damagedMeta(0); + when(clone.getItemMeta()).thenReturn(meta); + when(original.clone()).thenReturn(clone); + return clone; + } + + @Test + void differentMaterialNeverMatches() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.IRON_SWORD); + + ReturnItemEquivalence equivalence = new ReturnItemEquivalence(checks(false, false, false, false, false)); + assertFalse(equivalence.test(expected, actual)); + } + + @Test + void durabilityMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + ItemMeta expectedMeta = damagedMeta(0); + ItemMeta actualMeta = damagedMeta(100); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(true, false, false, false, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + @Test + void enchantmentMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + + ItemMeta expectedMeta = mock(ItemMeta.class); + ItemMeta actualMeta = mock(ItemMeta.class); + when(expectedMeta.getEnchants()).thenReturn(enchantsOf("sharpness", 3)); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(false, false, true, false, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Map enchantsOf(String key, int level) { + return (Map) Map.of(key, level); + } + + @Test + void nameMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + + ItemMeta expectedMeta = mock(ItemMeta.class); + ItemMeta actualMeta = mock(ItemMeta.class); + when(expectedMeta.displayName()).thenReturn(Component.text("Old Sword")); + when(actualMeta.displayName()).thenReturn(Component.text("New Sword")); + when(expectedMeta.getEnchants()).thenReturn(Map.of()); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(false, true, false, false, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + @Test + void loreMismatchFailsWhenChecked() { + ItemStack expected = item(Material.DIAMOND_SWORD); + ItemStack actual = item(Material.DIAMOND_SWORD); + + ItemMeta expectedMeta = mock(ItemMeta.class); + ItemMeta actualMeta = mock(ItemMeta.class); + when(expectedMeta.lore()).thenReturn(List.of(Component.text("Enchanted"))); + when(actualMeta.lore()).thenReturn(List.of()); + when(expectedMeta.getEnchants()).thenReturn(Map.of()); + when(actualMeta.getEnchants()).thenReturn(Map.of()); + when(expected.getItemMeta()).thenReturn(expectedMeta); + when(actual.getItemMeta()).thenReturn(actualMeta); + + assertFalse(new ReturnItemEquivalence(checks(false, false, false, true, false)).test(expected, actual)); + assertTrue(new ReturnItemEquivalence(checks(false, false, false, false, false)).test(expected, actual)); + } + + @Test + void missingMetaOnBothSidesMatches() { + ItemStack expected = item(Material.COBBLESTONE); + ItemStack actual = item(Material.COBBLESTONE); + when(expected.getItemMeta()).thenReturn(null); + when(actual.getItemMeta()).thenReturn(null); + + assertTrue(new ReturnItemEquivalence(checks(true, true, true, true, false)).test(expected, actual)); + } +} diff --git a/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java b/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java new file mode 100644 index 00000000..d9fbf7e4 --- /dev/null +++ b/src/test/java/com/eternalcode/parcellockers/returns/ReturnMismatchFormatterTest.java @@ -0,0 +1,72 @@ +package com.eternalcode.parcellockers.returns; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.eternalcode.multification.notice.Notice; +import com.eternalcode.multification.notice.resolver.chat.ChatContent; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import java.util.List; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +class ReturnMismatchFormatterTest { + + @Test + void formatsEveryConfiguredReasonWithItemSpecificValues() { + MessageConfig.ParcelMessages messages = new MessageConfig.ParcelMessages(); + messages.returnMismatchSeparator = " | "; + messages.returnMismatchUnexpectedItem = "unexpected:{ITEM}:{DEPOSITED_AMOUNT}"; + messages.returnMismatchInsufficientAmount = "insufficient:{ITEM}:{EXPECTED_AMOUNT}/{DEPOSITED_AMOUNT}"; + messages.returnMismatchExcessAmount = "excess:{ITEM}:{EXPECTED_AMOUNT}/{DEPOSITED_AMOUNT}"; + messages.returnMismatchDurability = "durability:{ITEM}:{EXPECTED_DAMAGE}/{DEPOSITED_DAMAGE}"; + messages.returnMismatchItemName = "name:{ITEM}"; + messages.returnMismatchEnchantments = "enchantments:{ITEM}"; + messages.returnMismatchLore = "lore:{ITEM}"; + messages.returnMismatchNbt = "nbt:{ITEM}"; + ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(messages); + + String formatted = formatter.format(new ParcelReturnValidationResult(List.of( + ReturnItemMismatch.unexpected(Material.DIRT, 2), + ReturnItemMismatch.insufficient(Material.DIAMOND, 5, 4), + ReturnItemMismatch.excess(Material.OAK_LOG, 64, 65), + new ReturnItemMismatch(ReturnMismatchType.DURABILITY, Material.DIAMOND_SWORD, 0, 0, 4, 27), + new ReturnItemMismatch(ReturnMismatchType.ITEM_NAME, Material.WRITTEN_BOOK, 0, 0, null, null), + new ReturnItemMismatch(ReturnMismatchType.ENCHANTMENTS, Material.BOW, 0, 0, null, null), + new ReturnItemMismatch(ReturnMismatchType.LORE, Material.PAPER, 0, 0, null, null), + new ReturnItemMismatch(ReturnMismatchType.NBT, Material.SHULKER_BOX, 0, 0, null, null) + ))); + + assertEquals( + "unexpected:Dirt:2 | insufficient:Diamond:5/4 | excess:Oak log:64/65" + + " | durability:Diamond sword:4/27 | name:Written book | enchantments:Bow" + + " | lore:Paper | nbt:Shulker box", + formatted + ); + } + + @Test + void appendsMismatchPlaceholderToLegacyConfiguredNotice() { + MessageConfig.ParcelMessages messages = new MessageConfig.ParcelMessages(); + messages.returnItemsMismatch = Notice.chat("legacy mismatch message"); + ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(messages); + ParcelReturnValidationResult mismatch = new ParcelReturnValidationResult(List.of( + ReturnItemMismatch.unexpected(Material.DIRT, 2) + )); + + Notice notice = formatter.notice(mismatch); + ChatContent chat = (ChatContent) notice.parts().getFirst().content(); + + assertEquals(List.of("legacy mismatch message", "{MISMATCHES}"), chat.messages()); + } + + @Test + void rejectsMatchingResultBecauseThereAreNoReasonsToFormat() { + ReturnMismatchFormatter formatter = new ReturnMismatchFormatter(new MessageConfig.ParcelMessages()); + + assertThrows( + IllegalArgumentException.class, + () -> formatter.format(new ParcelReturnValidationResult(List.of())) + ); + } +}