From 697114e049a65ba45c6b421bdfaf10a05fa0fae3 Mon Sep 17 00:00:00 2001 From: Siarhei Charniak Date: Tue, 24 Feb 2026 10:08:08 +0300 Subject: [PATCH 1/5] MODINV-1335 - Implement of PATCH requests for items (business layer) --- descriptors/ModuleDescriptor-template.json | 21 +++- pom.xml | 2 +- ...instance-patch.json => patch-request.json} | 0 ramls/inventory.raml | 35 +++++- ...instance_patch.json => patch_request.json} | 2 +- .../inventory/domain/items/LastCheckIn.java | 2 + .../org/folio/inventory/resources/Items.java | 109 ++++++++++++++-- .../validation/ItemStatusValidator.java | 6 + .../inventory/validation/ItemsValidator.java | 11 ++ src/test/java/api/items/ItemApiExamples.java | 118 ++++++++++++++++++ .../java/api/support/http/ResourceClient.java | 23 ++++ 11 files changed, 315 insertions(+), 14 deletions(-) rename ramls/examples/{instance-patch.json => patch-request.json} (100%) rename ramls/{instance_patch.json => patch_request.json} (87%) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index c3a2678f4..9d1d4e685 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -4,7 +4,7 @@ "provides": [ { "id": "inventory", - "version": "14.4", + "version": "14.5", "handlers": [ { "methods": ["GET"], @@ -351,6 +351,17 @@ "users.item.get" ] }, + { + "methods": ["PATCH"], + "pathPattern": "/inventory/items/{id}", + "permissionsRequired": ["inventory.items.item.patch"], + "modulePermissions": [ + "inventory-storage.items.item.get", + "inventory-storage.items.collection.get", + "inventory-storage.items.item.put", + "users.item.get" + ] + }, { "methods": ["DELETE"], "pathPattern": "/inventory/items/{id}", @@ -679,7 +690,7 @@ }, { "id": "item-storage", - "version": "11.0" + "version": "11.2" }, { "id": "instance-storage", @@ -877,6 +888,11 @@ "displayName": "Inventory - modify item", "description": "Modify item" }, + { + "permissionName": "inventory.items.item.patch", + "displayName": "Inventory - modify item partially", + "description": "Modify item partially" + }, { "permissionName": "inventory.items.item.delete", "displayName": "Inventory - delete individual item", @@ -953,6 +969,7 @@ "inventory.items.item.get", "inventory.items.item.post", "inventory.items.item.put", + "inventory.items.item.patch", "inventory.items.item.delete", "inventory.items.collection.delete", "inventory.instances.collection.get", diff --git a/pom.xml b/pom.xml index a4b55ba2d..9841eba45 100644 --- a/pom.xml +++ b/pom.xml @@ -479,7 +479,7 @@ ${basedir}/ramls/tenantItemPair.json ${basedir}/ramls/tenantItemPairCollection.json ${basedir}/ramls/tenantItemResponse.json - ${basedir}/ramls/instance_patch.json + ${basedir}/ramls/patch_request.json org.folio true diff --git a/ramls/examples/instance-patch.json b/ramls/examples/patch-request.json similarity index 100% rename from ramls/examples/instance-patch.json rename to ramls/examples/patch-request.json diff --git a/ramls/inventory.raml b/ramls/inventory.raml index 19c2a7387..394a547d3 100644 --- a/ramls/inventory.raml +++ b/ramls/inventory.raml @@ -17,7 +17,7 @@ types: instance: !include instance.json instances: !include instances.json tenantItemPairCollection: !include tenantItemPairCollection.json - instancePatchRequest: !include instance_patch.json + patchRequest: !include patch_request.json traits: language: !include raml-util/traits/language.raml @@ -76,6 +76,35 @@ resourceTypes: exampleItem: !include examples/item_get.json schema: item get: + patch: + description: Partial update of item with given ID + body: + application/json: + type: patchRequest + example: !include examples/patch-request.json + responses: + 204: + description: "Item successfully updated" + 404: + description: "Item with a given ID not found" + body: + text/plain: + example: Not found + 400: + description: "Bad request, e.g. malformed request body or query parameter." + body: + text/plain: + example: "unable to update instance - malformed JSON" + 409: + description: "Optimistic locking version conflict" + body: + text/plain: + example: "version conflict" + 500: + description: "Internal server error, e.g. due to misconfiguration" + body: + text/plain: + example: "internal server error, contact administrator" /mark-withdrawn: post: responses: @@ -398,8 +427,8 @@ resourceTypes: description: Partial update of instance with given ID body: application/json: - type: instancePatchRequest - example: !include examples/instance-patch.json + type: patchRequest + example: !include examples/patch-request.json responses: 204: description: "Instance successfully updated" diff --git a/ramls/instance_patch.json b/ramls/patch_request.json similarity index 87% rename from ramls/instance_patch.json rename to ramls/patch_request.json index bc3bc6f2c..015aaca79 100644 --- a/ramls/instance_patch.json +++ b/ramls/patch_request.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-04/schema#", "description": "An instance record patch request", - "javaType": "org.folio.rest.jaxrs.model.InstancePatchRequest", + "javaType": "org.folio.rest.jaxrs.model.PatchRequest", "type": "object", "properties": { "id": { diff --git a/src/main/java/org/folio/inventory/domain/items/LastCheckIn.java b/src/main/java/org/folio/inventory/domain/items/LastCheckIn.java index 9032da0ff..151cc286f 100644 --- a/src/main/java/org/folio/inventory/domain/items/LastCheckIn.java +++ b/src/main/java/org/folio/inventory/domain/items/LastCheckIn.java @@ -1,10 +1,12 @@ package org.folio.inventory.domain.items; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.vertx.core.json.JsonObject; import org.joda.time.DateTime; import org.joda.time.format.ISODateTimeFormat; +@JsonIgnoreProperties(ignoreUnknown = true) public class LastCheckIn { private final DateTime dateTime; diff --git a/src/main/java/org/folio/inventory/resources/Items.java b/src/main/java/org/folio/inventory/resources/Items.java index 58a8826d8..15545c8b2 100644 --- a/src/main/java/org/folio/inventory/resources/Items.java +++ b/src/main/java/org/folio/inventory/resources/Items.java @@ -1,13 +1,17 @@ package org.folio.inventory.resources; +import static java.util.concurrent.CompletableFuture.completedFuture; import static org.folio.HttpStatus.HTTP_OK; import static org.folio.inventory.common.FutureAssistance.allOf; +import static org.folio.inventory.support.CompletableFutures.failedFuture; import static org.folio.inventory.support.CqlHelper.multipleRecordsCqlQuery; import static org.folio.inventory.support.EndpointFailureHandler.doExceptionally; import static org.folio.inventory.support.EndpointFailureHandler.handleFailure; import static org.folio.inventory.support.ItemUtil.HOLDINGS_RECORD_ID; import static org.folio.inventory.support.http.server.JsonResponse.unprocessableEntity; +import static org.folio.inventory.validation.ItemStatusValidator.checkStatusIfPresent; import static org.folio.inventory.validation.ItemStatusValidator.itemHasCorrectStatus; +import static org.folio.inventory.validation.ItemsValidator.barcodeChanged; import static org.folio.inventory.validation.ItemsValidator.claimedReturnedMarkedAsMissing; import static org.folio.inventory.validation.ItemsValidator.hridChanged; @@ -99,6 +103,7 @@ public Items(final Storage storage, final HttpClient client) { public void register(Router router) { router.post(RELATIVE_ITEMS_PATH + "*").handler(BodyHandler.create()); router.put(RELATIVE_ITEMS_PATH + "*").handler(BodyHandler.create()); + router.patch(RELATIVE_ITEMS_PATH + "*").handler(BodyHandler.create()); router.get(RELATIVE_ITEMS_PATH).handler(this::getAll); router.post(RELATIVE_ITEMS_PATH + "/retrieve").handler(this::retrieveAllByCQLBody); @@ -107,6 +112,7 @@ public void register(Router router) { router.get(RELATIVE_ITEMS_PATH_ID).handler(this::getById); router.put(RELATIVE_ITEMS_PATH_ID).handler(this::update); + router.patch(RELATIVE_ITEMS_PATH_ID).handler(this::patch); router.delete(RELATIVE_ITEMS_PATH_ID).handler(this::deleteById); Arrays.stream(ItemStatusName.values()) @@ -293,6 +299,53 @@ private void update(RoutingContext routingContext) { }).exceptionally(doExceptionally(routingContext)); } + private void patch(RoutingContext routingContext) { + WebContext context = new WebContext(routingContext); + + var patchRequest = routingContext.body().asJsonObject(); + + Optional validationError = checkStatusIfPresent(patchRequest); + if (validationError.isPresent()) { + unprocessableEntity(routingContext.response(), validationError.get()); + return; + } + + ItemCollection itemCollection = storage.getItemCollection(context); + UserCollection userCollection = storage.getUserCollection(context); + + final String itemId = routingContext.request().getParam("id"); + final CompletableFuture> getItemFuture = new CompletableFuture<>(); + + itemCollection.findById(itemId, getItemFuture::complete, + FailureResponseConsumer.serverError(routingContext.response())); + + getItemFuture + .thenApply(Success::getResult) + .thenCompose(ItemsValidator::refuseWhenItemNotFound) + .thenCompose(oldItem -> + applyPatch(oldItem, patchRequest) + .thenCompose(patchedItem -> hridChanged(oldItem, patchedItem)) + .thenCompose(patchedItem -> barcodeChanged(oldItem, patchedItem)) + .thenCompose(patchedItem -> claimedReturnedMarkedAsMissing(oldItem, patchedItem)) + .thenCompose(patchedItem -> { + findUserAndPatchItem(routingContext, patchRequest, oldItem, userCollection, itemCollection); + return completedFuture(null); + } + ) + ) + .exceptionally(doExceptionally(routingContext)); + } + + private CompletableFuture applyPatch(Item existingItem, JsonObject patchJson) { + try { + JsonObject mergedJson = JsonObject.mapFrom(existingItem); + mergedJson.mergeIn(patchJson, false); + return completedFuture(ItemUtil.jsonToItem(mergedJson)); + } catch (Exception e) { + return failedFuture(e); + } + } + private void deleteById(RoutingContext routingContext) { WebContext context = new WebContext(routingContext); CollectionResourceClient itemsStorageClient; @@ -842,6 +895,19 @@ private void findUserAndUpdateItem( failure -> updateItem(routingContext, newItem, oldItem, null, itemCollection)); } + private void findUserAndPatchItem( + RoutingContext routingContext, + JsonObject patchJson, + Item oldItem, + UserCollection userCollection, + ItemCollection itemCollection) { + + String userId = routingContext.request().getHeader("X-Okapi-User-Id"); + userCollection.findById(userId, + success -> patchItem(routingContext, patchJson, oldItem, success.getResult(), itemCollection), + failure -> patchItem(routingContext, patchJson, oldItem, null, itemCollection)); + } + private void updateItem( RoutingContext routingContext, Item newItem, @@ -849,18 +915,47 @@ private void updateItem( User user, ItemCollection itemCollection) { - Map oldNotes = oldItem.getCirculationNotes() + List updatedNotes = updateCirculationNotes(oldItem.getCirculationNotes(), + newItem.getCirculationNotes(), user); + + itemCollection.update(newItem.withCirculationNotes(updatedNotes), + v -> SuccessResponse.noContent(routingContext.response()), + failure -> ForwardResponse.forward(routingContext.response(), failure)); + } + + private void patchItem( + RoutingContext routingContext, + JsonObject patchJson, + Item oldItem, + User user, + ItemCollection itemCollection) { + + if (patchJson.containsKey("circulationNotes")) { + var newItem = ItemUtil.jsonToItem(patchJson.mergeIn(JsonObject.mapFrom(oldItem), false)); + List updatedNotes = updateCirculationNotes(oldItem.getCirculationNotes(), + newItem.getCirculationNotes(), user); + var notesJson = new JsonArray( + updatedNotes.stream() + .map(JsonObject::mapFrom) + .toList()); + patchJson.put("circulationNotes", notesJson); + } + + itemCollection.patch(patchJson.getString("id"), patchJson, + v -> SuccessResponse.noContent(routingContext.response()), + failure -> ForwardResponse.forward(routingContext.response(), failure)); + } + + List updateCirculationNotes(List oldNotes, + List newNotes, User user) { + Map oldNoteList = oldNotes .stream() .collect(Collectors.toMap(CirculationNote::getId, Function.identity())); - List updatedNotes = newItem.getCirculationNotes() + return newNotes .stream() - .map(note -> updateCirculationNoteIfChanged(note, user, oldNotes)) + .map(note -> updateCirculationNoteIfChanged(note, user, oldNoteList)) .collect(Collectors.toList()); - - itemCollection.update(newItem.withCirculationNotes(updatedNotes), - v -> SuccessResponse.noContent(routingContext.response()), - failure -> ForwardResponse.forward(routingContext.response(), failure)); } private void checkForNonUniqueBarcode( diff --git a/src/main/java/org/folio/inventory/validation/ItemStatusValidator.java b/src/main/java/org/folio/inventory/validation/ItemStatusValidator.java index 323563a8a..e44d524c2 100644 --- a/src/main/java/org/folio/inventory/validation/ItemStatusValidator.java +++ b/src/main/java/org/folio/inventory/validation/ItemStatusValidator.java @@ -28,4 +28,10 @@ public static Optional itemHasCorrectStatus(JsonObject itemRequ return Optional.empty(); } + + public static Optional checkStatusIfPresent(JsonObject patchRequest) { + return patchRequest.containsKey("status") + ? itemHasCorrectStatus(patchRequest) + : Optional.empty(); + } } diff --git a/src/main/java/org/folio/inventory/validation/ItemsValidator.java b/src/main/java/org/folio/inventory/validation/ItemsValidator.java index 9958e40ee..b5a508f2f 100644 --- a/src/main/java/org/folio/inventory/validation/ItemsValidator.java +++ b/src/main/java/org/folio/inventory/validation/ItemsValidator.java @@ -45,6 +45,17 @@ public static CompletableFuture hridChanged(Item oldItem, Item newItem) { return completedFuture(oldItem); } + public static CompletableFuture barcodeChanged(Item oldItem, Item newItem) { + if (!Objects.equals(newItem.getBarcode(), oldItem.getBarcode())) { + final ValidationError validationError = new ValidationError( + "Barcode can not be patched", "barcode", newItem.getBarcode()); + + return failedFuture(new UnprocessableEntityException(validationError)); + } + + return completedFuture(oldItem); + } + private static boolean isClaimedReturnedItemMarkedMissing(Item oldItem, Item newItem) { return oldItem.getStatus().getName() == ItemStatusName.CLAIMED_RETURNED && newItem.getStatus().getName() == ItemStatusName.MISSING; diff --git a/src/test/java/api/items/ItemApiExamples.java b/src/test/java/api/items/ItemApiExamples.java index ecb3d555e..86cc1e5a6 100644 --- a/src/test/java/api/items/ItemApiExamples.java +++ b/src/test/java/api/items/ItemApiExamples.java @@ -1815,6 +1815,115 @@ public void canDeleteAdditionalCallNumbers() throws InterruptedException, assertThat(response.getStatusCode(), is(204)); } + @Test + public void canPatchExistingItem() + throws InterruptedException, + MalformedURLException, + TimeoutException, + ExecutionException { + + UUID TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE = UUID.randomUUID(); + UUID TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE = UUID.randomUUID(); + UUID holdingId = createInstanceAndHolding(); + UUID itemId = UUID.randomUUID(); + + JsonObject lastCheckIn = new JsonObject() + .put("servicePointId", "7c5abc9f-f3d7-4856-b8d7-6712462ca007") + .put("staffMemberId", "12115707-d7c8-54e7-8287-22e97f7250a4") + .put("dateTime", "2020-01-02T13:02:46.000Z"); + + JsonObject newItemRequest = new ItemRequestBuilder() + .withId(itemId) + .forHolding(holdingId) + .withInTransitDestinationServicePointId(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE) + .withBarcode("645398607547") + .canCirculate() + .temporarilyInReadingRoom() + .withTagList(new JsonObject().put(Item.TAG_LIST_KEY, new JsonArray().add("test-tag"))) + .withLastCheckIn(lastCheckIn) + .withCopyNumber("cp") + .create(); + + newItemRequest = itemsClient.create(newItemRequest).getJson(); + + assertThat(newItemRequest.getString("copyNumber"), is("cp")); + assertThat(newItemRequest.getString(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY), + is(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE.toString())); + + var patchRequest = new JsonObject() + .put("id", itemId) + .put("status", new JsonObject().put("name", "Checked out")) + .put("copyNumber", "updatedCp") + .put(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY, + TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE) + .put("tags", new JsonObject().put("tagList", new JsonArray().add(""))); + + itemsClient.patch(itemId, patchRequest); + + Response getResponse = itemsClient.getById(itemId); + + assertThat(getResponse.getStatusCode(), is(200)); + JsonObject updatedItem = getResponse.getJson(); + + assertThat(getTags(updatedItem), hasItem("")); + assertThat(updatedItem.containsKey("id"), is(true)); + assertThat(updatedItem.getString("title"), is("Long Way to a Small Angry Planet")); + assertThat(updatedItem.getString("barcode"), is("645398607547")); + assertThat(updatedItem.getJsonObject("status").getString("name"), is("Checked out")); + assertThat(updatedItem.getJsonObject(Item.LAST_CHECK_IN).getString("servicePointId"), + is("7c5abc9f-f3d7-4856-b8d7-6712462ca007")); + assertThat(updatedItem.getJsonObject(Item.LAST_CHECK_IN).getString("staffMemberId"), + is("12115707-d7c8-54e7-8287-22e97f7250a4")); + assertThat(updatedItem.getJsonObject(Item.LAST_CHECK_IN).getString("dateTime"), + is("2020-01-02T13:02:46.000Z")); + assertThat(updatedItem.getJsonObject("status").getString("name"), is("Checked out")); + + JsonObject materialType = updatedItem.getJsonObject("materialType"); + + assertThat(materialType.getString("id"), is(ApiTestSuite.getBookMaterialType())); + assertThat(materialType.getString("name"), is("Book")); + + JsonObject permanentLoanType = updatedItem.getJsonObject("permanentLoanType"); + + assertThat(permanentLoanType.getString("id"), is(ApiTestSuite.getCanCirculateLoanType())); + assertThat(permanentLoanType.getString("name"), is("Can Circulate")); + + assertThat("Item should not have permanent location", + updatedItem.containsKey("permanentLocation"), is(false)); + + assertThat(updatedItem.getJsonObject("temporaryLocation").getString("name"), is("Reading Room")); + + assertThat(updatedItem.getString("copyNumber"), is("updatedCp")); + assertThat(updatedItem.getString(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY), + is(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE.toString())); + } + + @Test + public void cannotPatchItemThatDoesNotExist() + throws InterruptedException, + MalformedURLException, + TimeoutException, + ExecutionException { + + UUID holdingId = createInstanceAndHolding(); + UUID itemId = UUID.randomUUID(); + + JsonObject updateItemRequest = new ItemRequestBuilder() + .withId(itemId) + .forHolding(holdingId) + .canCirculate() + .temporarilyInReadingRoom() + .create(); + + final var patchCompleted = okapiClient.patch( + String.format("%s/%s", ApiRoot.items(), updateItemRequest.getString("id")), + updateItemRequest); + + Response putResponse = patchCompleted.toCompletableFuture().get(5, SECONDS); + + assertThat(putResponse.getStatusCode(), is(404)); + } + private Response updateItem(JsonObject item) throws MalformedURLException, InterruptedException, ExecutionException, TimeoutException { @@ -1824,6 +1933,15 @@ private Response updateItem(JsonObject item) throws MalformedURLException, return putItemCompleted.toCompletableFuture().get(5, SECONDS); } + private Response patchItem(JsonObject item) throws MalformedURLException, + InterruptedException, ExecutionException, TimeoutException { + + String itemUpdateUri = String.format("%s/%s", ApiRoot.items(), item.getString("id")); + final var patchItemCompleted = okapiClient.patch(itemUpdateUri, item); + + return patchItemCompleted.toCompletableFuture().get(5, SECONDS); + } + private static void hasStatus(JsonObject item) { assertThat(item.containsKey("status"), is(true)); assertThat(item.getJsonObject("status").containsKey("name"), is(true)); diff --git a/src/test/java/api/support/http/ResourceClient.java b/src/test/java/api/support/http/ResourceClient.java index 52c80e9ce..e1fc16301 100644 --- a/src/test/java/api/support/http/ResourceClient.java +++ b/src/test/java/api/support/http/ResourceClient.java @@ -235,6 +235,29 @@ public Response attemptToReplace(UUID id, JsonObject request) return putCompleted.toCompletableFuture().get(5, SECONDS); } + public void patch(UUID id, JsonObject request) + throws MalformedURLException, + InterruptedException, + ExecutionException, + TimeoutException { + + Response patchResponse = attemptToPatch(id, request); + + assertThat( + String.format("Failed to update %s %s: %s", resourceName, id, patchResponse.getBody()), + patchResponse.getStatusCode(), is(HttpURLConnection.HTTP_NO_CONTENT)); + } + + public Response attemptToPatch(UUID id, JsonObject request) + throws MalformedURLException, InterruptedException, ExecutionException, + TimeoutException { + + final var patchCompleted = client.patch( + urlMaker.combine(String.format("/%s", id)).toString(), request); + + return patchCompleted.toCompletableFuture().get(5, SECONDS); + } + @SneakyThrows public Response getById(UUID id) { final var getCompleted = client.get(urlMaker.combine(String.format("/%s", id))); From 552caea1ea815a27554d2e62facf85b72e25e85e Mon Sep 17 00:00:00 2001 From: Siarhei Charniak Date: Tue, 24 Feb 2026 11:25:20 +0300 Subject: [PATCH 2/5] MODINV-1335 - Implement of PATCH requests for items (business layer) --- descriptors/ModuleDescriptor-template.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 9d1d4e685..df1017485 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -354,11 +354,11 @@ { "methods": ["PATCH"], "pathPattern": "/inventory/items/{id}", - "permissionsRequired": ["inventory.items.item.patch"], + "permissionsRequired": ["inventory.items.item.patch.execute"], "modulePermissions": [ "inventory-storage.items.item.get", "inventory-storage.items.collection.get", - "inventory-storage.items.item.put", + "inventory-storage.items.item.patch.execute", "users.item.get" ] }, @@ -484,9 +484,9 @@ { "methods": ["PATCH"], "pathPattern": "/inventory/instances/{id}", - "permissionsRequired": ["inventory.instances.item.patch"], + "permissionsRequired": ["inventory.instances.item.patch.execute"], "modulePermissions": [ - "inventory-storage.instances.item.patch", + "inventory-storage.instances.item.patch.execute", "inventory-storage.instances.item.get", "inventory-storage.instances.item.post", "inventory-storage.instances.item.delete", @@ -889,7 +889,7 @@ "description": "Modify item" }, { - "permissionName": "inventory.items.item.patch", + "permissionName": "inventory.items.item.patch.execute", "displayName": "Inventory - modify item partially", "description": "Modify item partially" }, @@ -939,7 +939,7 @@ "description": "Modify instance" }, { - "permissionName": "inventory.instances.item.patch", + "permissionName": "inventory.instances.item.patch.execute", "displayName": "Inventory - partially modify instance", "description": "Partially modify instance" }, @@ -969,7 +969,7 @@ "inventory.items.item.get", "inventory.items.item.post", "inventory.items.item.put", - "inventory.items.item.patch", + "inventory.items.item.patch.execute", "inventory.items.item.delete", "inventory.items.collection.delete", "inventory.instances.collection.get", @@ -978,7 +978,7 @@ "inventory.instances.item.post", "inventory.instances.batch.post", "inventory.instances.item.put", - "inventory.instances.item.patch", + "inventory.instances.item.patch.execute", "inventory.instances.item.delete", "inventory.instances.collection.delete", "inventory.config.instances.blocked-fields.get", From 468e0c1948300609df60cb2a11c6ad4f9bd31248 Mon Sep 17 00:00:00 2001 From: Siarhei Charniak Date: Tue, 24 Feb 2026 15:50:30 +0300 Subject: [PATCH 3/5] MODINV-1335 - Implement of PATCH requests for items (business layer) --- .../org/folio/inventory/resources/Items.java | 4 +- src/test/java/api/items/ItemApiExamples.java | 64 +++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/folio/inventory/resources/Items.java b/src/main/java/org/folio/inventory/resources/Items.java index 15545c8b2..6616ab045 100644 --- a/src/main/java/org/folio/inventory/resources/Items.java +++ b/src/main/java/org/folio/inventory/resources/Items.java @@ -324,8 +324,8 @@ private void patch(RoutingContext routingContext) { .thenCompose(ItemsValidator::refuseWhenItemNotFound) .thenCompose(oldItem -> applyPatch(oldItem, patchRequest) - .thenCompose(patchedItem -> hridChanged(oldItem, patchedItem)) - .thenCompose(patchedItem -> barcodeChanged(oldItem, patchedItem)) + .thenCompose(patchedItem -> hridChanged(oldItem, patchedItem).thenApply(x -> patchedItem)) + .thenCompose(patchedItem -> barcodeChanged(oldItem, patchedItem).thenApply(x -> patchedItem)) .thenCompose(patchedItem -> claimedReturnedMarkedAsMissing(oldItem, patchedItem)) .thenCompose(patchedItem -> { findUserAndPatchItem(routingContext, patchRequest, oldItem, userCollection, itemCollection); diff --git a/src/test/java/api/items/ItemApiExamples.java b/src/test/java/api/items/ItemApiExamples.java index 86cc1e5a6..b4f020b73 100644 --- a/src/test/java/api/items/ItemApiExamples.java +++ b/src/test/java/api/items/ItemApiExamples.java @@ -1852,6 +1852,7 @@ public void canPatchExistingItem() var patchRequest = new JsonObject() .put("id", itemId) + .put("barcode", newItemRequest.getString("barcode")) .put("status", new JsonObject().put("name", "Checked out")) .put("copyNumber", "updatedCp") .put(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY, @@ -1908,7 +1909,7 @@ public void cannotPatchItemThatDoesNotExist() UUID holdingId = createInstanceAndHolding(); UUID itemId = UUID.randomUUID(); - JsonObject updateItemRequest = new ItemRequestBuilder() + JsonObject patchItemRequest = new ItemRequestBuilder() .withId(itemId) .forHolding(holdingId) .canCirculate() @@ -1916,12 +1917,65 @@ public void cannotPatchItemThatDoesNotExist() .create(); final var patchCompleted = okapiClient.patch( - String.format("%s/%s", ApiRoot.items(), updateItemRequest.getString("id")), - updateItemRequest); + String.format("%s/%s", ApiRoot.items(), patchItemRequest.getString("id")), + patchItemRequest); - Response putResponse = patchCompleted.toCompletableFuture().get(5, SECONDS); + Response patchResponse = patchCompleted.toCompletableFuture().get(5, SECONDS); - assertThat(putResponse.getStatusCode(), is(404)); + assertThat(patchResponse.getStatusCode(), is(404)); + } + + @Test + public void cannotPatchItemIfBarcodeWasChanged() + throws InterruptedException, + MalformedURLException, + TimeoutException, + ExecutionException { + + UUID TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE = UUID.randomUUID(); + UUID TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE = UUID.randomUUID(); + UUID holdingId = createInstanceAndHolding(); + UUID itemId = UUID.randomUUID(); + + JsonObject lastCheckIn = new JsonObject() + .put("servicePointId", "7c5abc9f-f3d7-4856-b8d7-6712462ca007") + .put("staffMemberId", "12115707-d7c8-54e7-8287-22e97f7250a4") + .put("dateTime", "2020-01-02T13:02:46.000Z"); + + JsonObject newItemRequest = new ItemRequestBuilder() + .withId(itemId) + .forHolding(holdingId) + .withInTransitDestinationServicePointId(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE) + .withBarcode("645398607547") + .canCirculate() + .temporarilyInReadingRoom() + .withTagList(new JsonObject().put(Item.TAG_LIST_KEY, new JsonArray().add("test-tag"))) + .withLastCheckIn(lastCheckIn) + .withCopyNumber("cp") + .create(); + + newItemRequest = itemsClient.create(newItemRequest).getJson(); + + assertThat(newItemRequest.getString("copyNumber"), is("cp")); + assertThat(newItemRequest.getString(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY), + is(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE.toString())); + + var patchRequest = new JsonObject() + .put("id", itemId) + .put("barcode", "new_barcode") + .put("status", new JsonObject().put("name", "Checked out")) + .put("copyNumber", "updatedCp") + .put(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY, + TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE) + .put("tags", new JsonObject().put("tagList", new JsonArray().add(""))); + + final var patchCompleted = okapiClient.patch( + String.format("%s/%s", ApiRoot.items(), patchRequest.getString("id")), + patchRequest); + + Response patchResponse = patchCompleted.toCompletableFuture().get(5, SECONDS); + + assertThat(patchResponse.getStatusCode(), is(422)); } private Response updateItem(JsonObject item) throws MalformedURLException, From b73da18ea551600b3a108a4ecf70c5c086c90962 Mon Sep 17 00:00:00 2001 From: Siarhei Charniak Date: Tue, 24 Feb 2026 16:36:57 +0300 Subject: [PATCH 4/5] MODINV-1335 - Implement of PATCH requests for items (business layer) --- src/test/java/api/items/ItemApiExamples.java | 60 +++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/test/java/api/items/ItemApiExamples.java b/src/test/java/api/items/ItemApiExamples.java index b4f020b73..6db672256 100644 --- a/src/test/java/api/items/ItemApiExamples.java +++ b/src/test/java/api/items/ItemApiExamples.java @@ -1978,22 +1978,66 @@ public void cannotPatchItemIfBarcodeWasChanged() assertThat(patchResponse.getStatusCode(), is(422)); } - private Response updateItem(JsonObject item) throws MalformedURLException, - InterruptedException, ExecutionException, TimeoutException { + @Test + public void cannotPatchItemWithIncorrectStatus() + throws InterruptedException, + MalformedURLException, + TimeoutException, + ExecutionException { - String itemUpdateUri = String.format("%s/%s", ApiRoot.items(), item.getString("id")); - final var putItemCompleted = okapiClient.put(itemUpdateUri, item); + UUID TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE = UUID.randomUUID(); + UUID TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE = UUID.randomUUID(); + UUID holdingId = createInstanceAndHolding(); + UUID itemId = UUID.randomUUID(); - return putItemCompleted.toCompletableFuture().get(5, SECONDS); + JsonObject lastCheckIn = new JsonObject() + .put("servicePointId", "7c5abc9f-f3d7-4856-b8d7-6712462ca007") + .put("staffMemberId", "12115707-d7c8-54e7-8287-22e97f7250a4") + .put("dateTime", "2020-01-02T13:02:46.000Z"); + + JsonObject newItemRequest = new ItemRequestBuilder() + .withId(itemId) + .forHolding(holdingId) + .withInTransitDestinationServicePointId(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE) + .withBarcode("645398607547") + .canCirculate() + .temporarilyInReadingRoom() + .withTagList(new JsonObject().put(Item.TAG_LIST_KEY, new JsonArray().add("test-tag"))) + .withLastCheckIn(lastCheckIn) + .withCopyNumber("cp") + .create(); + + newItemRequest = itemsClient.create(newItemRequest).getJson(); + + assertThat(newItemRequest.getString("copyNumber"), is("cp")); + assertThat(newItemRequest.getString(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY), + is(TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_CREATE.toString())); + + var patchRequest = new JsonObject() + .put("id", itemId) + .put("barcode", "new_barcode") + .put("status", new JsonObject().put("name", "Invalid status")) + .put("copyNumber", "updatedCp") + .put(Item.TRANSIT_DESTINATION_SERVICE_POINT_ID_KEY, + TRANSIT_DESTINATION_SERVICE_POINT_ID_FOR_UPDATE) + .put("tags", new JsonObject().put("tagList", new JsonArray().add(""))); + + final var patchCompleted = okapiClient.patch( + String.format("%s/%s", ApiRoot.items(), patchRequest.getString("id")), + patchRequest); + + Response patchResponse = patchCompleted.toCompletableFuture().get(5, SECONDS); + + assertThat(patchResponse.getStatusCode(), is(422)); } - private Response patchItem(JsonObject item) throws MalformedURLException, + private Response updateItem(JsonObject item) throws MalformedURLException, InterruptedException, ExecutionException, TimeoutException { String itemUpdateUri = String.format("%s/%s", ApiRoot.items(), item.getString("id")); - final var patchItemCompleted = okapiClient.patch(itemUpdateUri, item); + final var putItemCompleted = okapiClient.put(itemUpdateUri, item); - return patchItemCompleted.toCompletableFuture().get(5, SECONDS); + return putItemCompleted.toCompletableFuture().get(5, SECONDS); } private static void hasStatus(JsonObject item) { From fb516bdeb0006b8e2cd35e32324d2beb4dbf5a09 Mon Sep 17 00:00:00 2001 From: Siarhei Charniak Date: Tue, 24 Feb 2026 23:06:21 +0300 Subject: [PATCH 5/5] MODINV-1335 - Implement of PATCH requests for items (business layer) --- descriptors/ModuleDescriptor-template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index df1017485..a96d500b2 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -4,7 +4,7 @@ "provides": [ { "id": "inventory", - "version": "14.5", + "version": "14.4", "handlers": [ { "methods": ["GET"],