From 09035f02cdf6b5e434963f7123019ec0b89e6f6d Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Wed, 24 Dec 2025 12:56:36 +0200 Subject: [PATCH 1/6] MODRTACHCE-21: Implement Cache generation and Kafka listeners logic for Bound With Items --- .../rtaccache/client/InventoryClient.java | 4 + .../integration/KafkaMessageListener.java | 21 ++- .../repository/RtacHoldingRepository.java | 26 ---- .../service/RtacCacheGenerationService.java | 81 ++++++++-- .../service/RtacHoldingMappingService.java | 27 ++++ .../impl/ItemBoundWithCreateEventHandler.java | 72 +++++++++ .../impl/ItemBoundWithDeleteEventHandler.java | 49 ++++++ src/main/resources/application.yml | 4 + .../resources/swagger.api/mod-rtac-cache.yaml | 4 + .../schemas/inventory/bound-with-part.json | 28 ++++ .../schemas/inventory/bound-with-parts.json | 23 +++ .../schemas/kafka/inventoryEntityType.json | 1 + .../swagger.api/schemas/rtacHolding.json | 5 + .../org/folio/rtaccache/TestConstant.java | 1 + .../KafkaIntegrationTestConfig.java | 7 + .../integration/KafkaMessageListenerIT.java | 61 +++++++- .../service/RtacCacheGenerationServiceIT.java | 23 ++- .../ItemBoundWithCreateEventHandlerTest.java | 144 ++++++++++++++++++ .../ItemBoundWithDeleteEventHandlerTest.java | 95 ++++++++++++ .../bound-with-holdings-record-response.json | 35 +++++ .../cache-generation/bound-with-response.json | 14 ++ .../empty-bound-with-response.json | 4 + ...sponse.json => empty-pieces-response.json} | 0 .../holding3-items-response.json | 9 ++ .../holding4-items-response.json | 42 +++++ .../kafka-events/create-bound-with-event.json | 16 ++ .../kafka-events/delete-bound-with-event.json | 16 ++ src/test/resources/application-test.yml | 20 +-- .../resources/mappings/cache-generation.json | 120 ++++++++++++++- 29 files changed, 885 insertions(+), 67 deletions(-) create mode 100644 src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandler.java create mode 100644 src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java create mode 100644 src/main/resources/swagger.api/schemas/inventory/bound-with-part.json create mode 100644 src/main/resources/swagger.api/schemas/inventory/bound-with-parts.json create mode 100644 src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java create mode 100644 src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java create mode 100644 src/test/resources/__files/cache-generation/bound-with-holdings-record-response.json create mode 100644 src/test/resources/__files/cache-generation/bound-with-response.json create mode 100644 src/test/resources/__files/cache-generation/empty-bound-with-response.json rename src/test/resources/__files/cache-generation/{holding2-empty-pieces-response.json => empty-pieces-response.json} (100%) create mode 100644 src/test/resources/__files/cache-generation/holding3-items-response.json create mode 100644 src/test/resources/__files/cache-generation/holding4-items-response.json create mode 100644 src/test/resources/__files/kafka-events/create-bound-with-event.json create mode 100644 src/test/resources/__files/kafka-events/delete-bound-with-event.json diff --git a/src/main/java/org/folio/rtaccache/client/InventoryClient.java b/src/main/java/org/folio/rtaccache/client/InventoryClient.java index 87f33d2..1fe36e4 100644 --- a/src/main/java/org/folio/rtaccache/client/InventoryClient.java +++ b/src/main/java/org/folio/rtaccache/client/InventoryClient.java @@ -1,5 +1,6 @@ package org.folio.rtaccache.client; +import org.folio.rtaccache.domain.dto.BoundWithParts; import org.folio.rtaccache.domain.dto.FolioCqlRequest; import org.folio.rtaccache.domain.dto.HoldingRecords; import org.folio.rtaccache.domain.dto.HoldingsNoteTypes; @@ -27,6 +28,9 @@ public interface InventoryClient { @PostMapping("/item-storage/items/retrieve") Items getItems(@RequestBody FolioCqlRequest request); + @GetMapping("/inventory-storage/bound-with-parts") + BoundWithParts getBoundWithParts(@SpringQueryMap FolioCqlRequest request); + @GetMapping("/locations") Locations getLocations(@SpringQueryMap FolioCqlRequest request); diff --git a/src/main/java/org/folio/rtaccache/integration/KafkaMessageListener.java b/src/main/java/org/folio/rtaccache/integration/KafkaMessageListener.java index 7fdc657..77afbab 100644 --- a/src/main/java/org/folio/rtaccache/integration/KafkaMessageListener.java +++ b/src/main/java/org/folio/rtaccache/integration/KafkaMessageListener.java @@ -4,6 +4,7 @@ import static org.folio.rtaccache.domain.dto.CirculationEntityType.REQUEST; import static org.folio.rtaccache.domain.dto.InventoryEntityType.HOLDINGS; import static org.folio.rtaccache.domain.dto.InventoryEntityType.ITEM; +import static org.folio.rtaccache.domain.dto.InventoryEntityType.ITEM_BOUND_WITH; import static org.folio.rtaccache.domain.dto.InventoryEntityType.LIBRARY; import static org.folio.rtaccache.domain.dto.InventoryEntityType.LOCATION; @@ -29,6 +30,7 @@ public class KafkaMessageListener { private static final String PIECE_LISTENER_ID = "mod-rtac-cache-piece-listener"; private static final String LOCATIONS_LISTENER_ID = "mod-rtac-cache-location-listener"; private static final String LIBRARIES_LISTENER_ID = "mod-rtac-cache-library-listener"; + private static final String BOUND_WITH_LISTENER_ID = "mod-rtac-cache-bound-with-listener"; private static final String FOLIO_TENANT_ID_HEADER = "folio.tenantId"; private final SystemUserScopedExecutionService executionService; @@ -117,7 +119,7 @@ public void handlePieceRecord(ConsumerRecord consume concurrency = "#{folioKafkaProperties.listener['location'].concurrency}", topicPattern = "#{folioKafkaProperties.listener['location'].topicPattern}", autoStartup = "false") - public void handleLocation(ConsumerRecord consumerRecord) { + public void handleLocationRecord(ConsumerRecord consumerRecord) { var tenantId = consumerRecord.value().getTenant(); executionService.executeAsyncSystemUserScoped(tenantId, () -> { var resourceEvent = consumerRecord.value(); @@ -133,7 +135,7 @@ public void handleLocation(ConsumerRecord consum concurrency = "#{folioKafkaProperties.listener['library'].concurrency}", topicPattern = "#{folioKafkaProperties.listener['library'].topicPattern}", autoStartup = "false") - public void handleLibrary(ConsumerRecord consumerRecord) { + public void handleLibraryRecord(ConsumerRecord consumerRecord) { var tenantId = consumerRecord.value().getTenant(); executionService.executeAsyncSystemUserScoped(tenantId, () -> { var resourceEvent = consumerRecord.value(); @@ -142,6 +144,21 @@ public void handleLibrary(ConsumerRecord consume } ); } + @KafkaListener( + id = BOUND_WITH_LISTENER_ID, + containerFactory = "inventoryKafkaListenerContainerFactory", + groupId = "#{folioKafkaProperties.listener['bound-with'].groupId}", + concurrency = "#{folioKafkaProperties.listener['bound-with'].concurrency}", + topicPattern = "#{folioKafkaProperties.listener['bound-with'].topicPattern}") + public void handleBoundWithRecord(ConsumerRecord consumerRecord) { + var tenantId = consumerRecord.value().getTenant(); + executionService.executeAsyncSystemUserScoped(tenantId, () -> { + var resourceEvent = consumerRecord.value(); + eventHandlerFactory.getInventoryHandler(resourceEvent.getType(), ITEM_BOUND_WITH) + .handle(resourceEvent); + } ); + } + private String getFolioTenantFromHeader(ConsumerRecord consumerRecord) { return new String(consumerRecord .headers() diff --git a/src/main/java/org/folio/rtaccache/repository/RtacHoldingRepository.java b/src/main/java/org/folio/rtaccache/repository/RtacHoldingRepository.java index e8850be..b68eeea 100644 --- a/src/main/java/org/folio/rtaccache/repository/RtacHoldingRepository.java +++ b/src/main/java/org/folio/rtaccache/repository/RtacHoldingRepository.java @@ -41,12 +41,6 @@ public interface RtacHoldingRepository extends JpaRepository findAllByHoldingsId(@Param("holdingsId") String holdingsId); - @Query(value = "SELECT * FROM rtac_holding WHERE rtac_holding_json->'location'->>'id' = :locationId", nativeQuery = true) - List findAllByLocationId(@Param("locationId") String locationId); - - @Query(value = "SELECT * FROM rtac_holding WHERE rtac_holding_json->'library'->>'id' = :libraryId", nativeQuery = true) - List findAllByLibraryId(@Param("libraryId") String libraryId); - @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(value = "DELETE FROM rtac_holding WHERE rtac_holding_json->>'holdingsId' = :holdingsId", nativeQuery = true) void deleteAllByHoldingsId(@Param("holdingsId") String holdingsId); @@ -105,24 +99,4 @@ LocationStatusCounts AS ( nativeQuery = true) List findRtacSummariesByInstanceIds(@Param("schemas") String schemas, @Param("instanceIds") UUID[] instanceIds, @Param("onlyShared") boolean onlyShared); - @Modifying - @Query(value = """ - UPDATE rtac_holding_entity - SET rtac_holding_json = jsonb_set( - jsonb_set( - rtac_holding_json, - '{location,name}', - to_jsonb(:name::text) - ), - '{location,code}', - to_jsonb(:code::text) - ) - WHERE id IN ( - SELECT id FROM rtac_holding_entity - WHERE rtac_holding_json->'location'->>'id' = :locationId - ) - """, nativeQuery = true) - int updateLocationDataBatch(@Param("locationId") String locationId, - @Param("name") String name, - @Param("code") String code); } diff --git a/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java b/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java index 11bb2a8..af55784 100644 --- a/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java +++ b/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java @@ -8,9 +8,11 @@ import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.folio.rtaccache.client.InventoryClient; import org.folio.rtaccache.domain.RtacHoldingEntity; import org.folio.rtaccache.domain.RtacHoldingId; +import org.folio.rtaccache.domain.dto.BoundWithPart; import org.folio.rtaccache.domain.dto.FolioCqlRequest; import org.folio.rtaccache.domain.dto.HoldingsRecord; import org.folio.rtaccache.domain.dto.Instance; @@ -30,7 +32,7 @@ public class RtacCacheGenerationService { @Qualifier("applicationTaskExecutor") private final AsyncTaskExecutor taskExecutor; private final InventoryClient inventoryClient; - private final RtacHoldingBulkRepository rtacHoldingRepository; + private final RtacHoldingBulkRepository rtacHoldingBulkRepository; private final RtacHoldingMappingService rtacHoldingMappingService; private final CirculationService circulationService; private final OrdersService ordersService; @@ -38,6 +40,7 @@ public class RtacCacheGenerationService { private static final Integer HOLDINGS_BATCH_SIZE = 50; private static final Integer ITEMS_BATCH_SIZE = 500; private static final String CONSORTIUM_SOURCE = "CONSORTIUM"; + private static final Integer BOUND_WITH_BATCH_SIZE = 500; public CompletableFuture generateRtacCache(String instanceId) { log.info("Started RTAC cache generation for instance id: {} in tenant: {}", instanceId, folioExecutionContext.getTenantId()); @@ -65,7 +68,8 @@ private Runnable processIndividualHolding(Instance instance, HoldingsRecord hold return () -> { log.info("Processing holding id : {}", holding.getId()); saveHolding(instance, holding); - var itemsFuture = processItemsForHolding(instance, holding); + var itemsFuture = processDirectItemsForHolding(instance, holding) + .thenCompose(v -> processItemsBoundWithHolding(instance, holding)); var piecesFuture = processPiecesForHolding(instance, holding); itemsFuture.join(); piecesFuture.join(); @@ -77,25 +81,60 @@ private void saveHolding(Instance instance, HoldingsRecord holding) { var entityId = RtacHoldingId.from(rtacHolding); var rtacHoldingEntity = new RtacHoldingEntity(entityId, isInstanceShared(instance), rtacHolding, Instant.now()); try { - rtacHoldingRepository.bulkUpsert(List.of(rtacHoldingEntity)); + rtacHoldingBulkRepository.bulkUpsert(List.of(rtacHoldingEntity)); } catch (Exception e) { log.error("Error during bulk upsert of RTAC holdings for holding: {}", e.getMessage(), e); } } - private CompletableFuture processItemsForHolding(Instance instance, HoldingsRecord holding) { + private CompletableFuture processDirectItemsForHolding(Instance instance, HoldingsRecord holding) { var itemsOffset = 0; var totalItems = getItemsTotalRecords(holding.getId()); var futures = new ArrayList>(); while (totalItems != 0 && itemsOffset < totalItems) { - var itemsCql = getItemsByHoldingIdCql(holding.getId()); + var itemsCql = getByHoldingsIdCql(holding.getId()); var itemsRequest = new FolioCqlRequest(itemsCql, ITEMS_BATCH_SIZE, itemsOffset); - futures.add(processItemsBatch(instance, holding, itemsRequest)); + futures.add(processItemsBatch(instance, holding, itemsRequest, false)); itemsOffset += ITEMS_BATCH_SIZE; } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); } + private CompletableFuture processItemsBoundWithHolding(Instance instance, HoldingsRecord holdings) { + log.info("Processing bound-with items for holding id : {}", holdings.getId()); + var boundWithPartOffset = 0; + var totalBoundWithParts = getBoundWithTotal(holdings); + log.info("Total bound-with parts for holding id {} : {}", holdings.getId(), totalBoundWithParts); + var futures = new ArrayList>(); + while (totalBoundWithParts != 0 && boundWithPartOffset < totalBoundWithParts) { + var boundWithPartsCql = getByHoldingsIdCql(holdings.getId()); + var boundWithPartsRequest = new FolioCqlRequest(boundWithPartsCql, ITEMS_BATCH_SIZE, boundWithPartOffset); + futures.add(processBondWithItemBatch(instance, holdings, boundWithPartsRequest)); + boundWithPartOffset += BOUND_WITH_BATCH_SIZE; + } + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + } + + private CompletableFuture processBondWithItemBatch(Instance instance, HoldingsRecord holdings, FolioCqlRequest boundWithPartsRequest) { + return CompletableFuture.supplyAsync(() -> { + var boundWithPartsResponse = inventoryClient.getBoundWithParts(boundWithPartsRequest); + log.info("Fetched {} bound-with parts for holding id: {}", boundWithPartsResponse.getTotalRecords(), holdings.getId()); + return boundWithPartsResponse.getBoundWithParts() + .stream() + .map(BoundWithPart::getItemId) + .toList(); + }, taskExecutor) + .thenComposeAsync(boundWithItemIds -> { + if (CollectionUtils.isEmpty(boundWithItemIds)) { + log.info("No bound-with itemIds collected for holding id: {}", holdings.getId()); + return CompletableFuture.completedFuture(null); + } + var queryParamValue = buildIdOrCql(boundWithItemIds); + var folioCqlRequest = new FolioCqlRequest(queryParamValue, boundWithItemIds.size(), 0); + return processItemsBatch(instance, holdings, folioCqlRequest, true); + }, taskExecutor); + } + private CompletableFuture processPiecesForHolding(Instance instance, HoldingsRecord holding) { return CompletableFuture.supplyAsync(() -> { log.info("Sending request for pieces for holding id: {}", holding.getId()); @@ -110,7 +149,7 @@ private CompletableFuture processPiecesForHolding(Instance instance, Holdi RtacHoldingId.from(rtacHolding), isInstanceShared(instance), rtacHolding, Instant.now())) .toList(); try { - rtacHoldingRepository.bulkUpsert(rtacHoldings); + rtacHoldingBulkRepository.bulkUpsert(rtacHoldings); log.info("Saved pieces for holding: {}", holding.getId()); } catch (Exception e) { log.error("Error during bulk upsert of RTAC holdings for pieces: {}", e.getMessage(), e); @@ -119,7 +158,7 @@ private CompletableFuture processPiecesForHolding(Instance instance, Holdi }, taskExecutor); } - private CompletableFuture processItemsBatch(Instance instance, HoldingsRecord holding, FolioCqlRequest request) { + private CompletableFuture processItemsBatch(Instance instance, HoldingsRecord holding, FolioCqlRequest request, boolean isBoundWith) { return CompletableFuture.supplyAsync(() -> { log.info("Sending request for items batch for holding id: {}, offset {}", holding.getId(), request.getOffset()); var itemsResponse = inventoryClient.getItems(request); @@ -129,10 +168,10 @@ private CompletableFuture processItemsBatch(Instance instance, HoldingsRec var itemsHoldCountMap = retrieveItemsHoldCountMap(items); var itemsLoanDueDateMap = retrieveItemsLoanDueDateMap(items); var rtacHoldings = items.stream() - .map(item -> processIndividualItem(instance, holding, item, itemsLoanDueDateMap, itemsHoldCountMap)) + .map(item -> processIndividualItem(instance, holding, item, itemsLoanDueDateMap, itemsHoldCountMap, isBoundWith)) .toList(); try { - rtacHoldingRepository.bulkUpsert(rtacHoldings); + rtacHoldingBulkRepository.bulkUpsert(rtacHoldings); log.info("Saved items batch for holding: {} offset: {}", holding.getId(), request.getOffset()); } catch (Exception e) { log.error("Error during bulk upsert of RTAC holdings: {}", e.getMessage(), e); @@ -154,10 +193,11 @@ private boolean isInstanceShared(Instance instance) { return instance.getSource() != null && instance.getSource().contains(CONSORTIUM_SOURCE); } - private RtacHoldingEntity processIndividualItem(Instance instance, HoldingsRecord holding, Item item, Map dueDateMap, Map holdCountMap) { + private RtacHoldingEntity processIndividualItem(Instance instance, HoldingsRecord holding, Item item, Map dueDateMap, Map holdCountMap, boolean isBoundWith) { var rtacHolding = rtacHoldingMappingService.mapFrom(holding, item); rtacHolding.setDueDate(dueDateMap.getOrDefault(rtacHolding.getId(), null)); rtacHolding.setTotalHoldRequests(Math.toIntExact(holdCountMap.getOrDefault(rtacHolding.getId(), 0L))); + rtacHolding.setIsBoundWith(isBoundWith); var entityId = RtacHoldingId.from(rtacHolding); return new RtacHoldingEntity(entityId, isInstanceShared(instance), rtacHolding, Instant.now()); } @@ -191,14 +231,27 @@ private Integer getHoldingsTotalRecords(String instanceId) { return holdingsResponse.getTotalRecords(); } - private String getItemsByHoldingIdCql(String holdingId) { - return "holdingsRecordId==" + holdingId; + private String getByHoldingsIdCql(String holdingsId) { + return "holdingsRecordId==" + holdingsId; } private Integer getItemsTotalRecords(String holdingId) { var itemsResponse = inventoryClient.getItems( - new FolioCqlRequest(getItemsByHoldingIdCql(holdingId), 0, 0) + new FolioCqlRequest(getByHoldingsIdCql(holdingId), 0, 0) ); return itemsResponse.getTotalRecords(); } + + private int getBoundWithTotal(HoldingsRecord holdings) { + var getByHoldingsIdCql = getByHoldingsIdCql(holdings.getId()); + var request = new FolioCqlRequest(getByHoldingsIdCql, 0, 0); + var response = inventoryClient.getBoundWithParts(request); + return response.getTotalRecords(); + } + + private String buildIdOrCql(List ids) { + return ids.stream().map(id -> "id==" + id).collect(java.util.stream.Collectors.joining(" or ")); + } + + } diff --git a/src/main/java/org/folio/rtaccache/service/RtacHoldingMappingService.java b/src/main/java/org/folio/rtaccache/service/RtacHoldingMappingService.java index fb92e96..6bb5ebc 100644 --- a/src/main/java/org/folio/rtaccache/service/RtacHoldingMappingService.java +++ b/src/main/java/org/folio/rtaccache/service/RtacHoldingMappingService.java @@ -153,6 +153,33 @@ public RtacHolding mapForItemTypeFrom(RtacHolding existingRtacHolding, Item item return newRtacHolding; } + public RtacHolding mapForBoundWithItemTypeFrom(RtacHolding holdingsRtacHolding, RtacHolding itemRtacHolding) { + var newRtacHolding = new RtacHolding(); + newRtacHolding.setId(itemRtacHolding.getId()); + newRtacHolding.setType(TypeEnum.ITEM); + newRtacHolding.setInstanceId(holdingsRtacHolding.getInstanceId()); + newRtacHolding.setHoldingsId(holdingsRtacHolding.getHoldingsId()); + newRtacHolding.setBarcode(itemRtacHolding.getBarcode()); + newRtacHolding.setCallNumber(itemRtacHolding.getCallNumber()); + newRtacHolding.setHoldingsCopyNumber(holdingsRtacHolding.getHoldingsCopyNumber()); + newRtacHolding.setItemCopyNumber(itemRtacHolding.getItemCopyNumber()); + newRtacHolding.setVolume(itemRtacHolding.getVolume()); + newRtacHolding.setEffectiveShelvingOrder(itemRtacHolding.getEffectiveShelvingOrder()); + newRtacHolding.setStatus(itemRtacHolding.getStatus()); + newRtacHolding.setSuppressFromDiscovery(itemRtacHolding.getSuppressFromDiscovery()); + newRtacHolding.setLocation(itemRtacHolding.getLocation()); + newRtacHolding.setLibrary(itemRtacHolding.getLibrary()); + newRtacHolding.setMaterialType(itemRtacHolding.getMaterialType()); + newRtacHolding.setTemporaryLoanType(itemRtacHolding.getTemporaryLoanType()); + newRtacHolding.setPermanentLoanType(itemRtacHolding.getPermanentLoanType()); + newRtacHolding.setHoldingsStatements(holdingsRtacHolding.getHoldingsStatements()); + newRtacHolding.setHoldingsStatementsForIndexes(holdingsRtacHolding.getHoldingsStatementsForIndexes()); + newRtacHolding.setHoldingsStatementsForSupplements(holdingsRtacHolding.getHoldingsStatementsForSupplements()); + newRtacHolding.setNotes(holdingsRtacHolding.getNotes()); + newRtacHolding.setIsBoundWith(true); + return newRtacHolding; + } + public RtacHolding mapForPieceTypeFrom(RtacHolding existingRtacHolding, HoldingsRecord holding) { var newRtacHolding = new RtacHolding(); newRtacHolding.setId(existingRtacHolding.getId()); diff --git a/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandler.java b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandler.java new file mode 100644 index 0000000..5859dee --- /dev/null +++ b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandler.java @@ -0,0 +1,72 @@ +package org.folio.rtaccache.service.handler.impl; + +import static java.util.UUID.fromString; +import static org.folio.rtaccache.domain.dto.RtacHolding.TypeEnum.HOLDING; +import static org.folio.rtaccache.domain.dto.RtacHolding.TypeEnum.ITEM; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.folio.rtaccache.domain.RtacHoldingEntity; +import org.folio.rtaccache.domain.RtacHoldingId; +import org.folio.rtaccache.domain.dto.BoundWithPart; +import org.folio.rtaccache.domain.dto.InventoryEntityType; +import org.folio.rtaccache.domain.dto.InventoryEventType; +import org.folio.rtaccache.domain.dto.InventoryResourceEvent; +import org.folio.rtaccache.repository.RtacHoldingRepository; +import org.folio.rtaccache.service.RtacHoldingMappingService; +import org.folio.rtaccache.service.handler.InventoryEventHandler; +import org.folio.rtaccache.util.ResourceEventUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ItemBoundWithCreateEventHandler implements InventoryEventHandler { + + private final RtacHoldingMappingService rtacHoldingMappingService; + private final RtacHoldingRepository rtacHoldingRepository; + private final ResourceEventUtil resourceEventUtil; + + @Override + @Transactional + public void handle(InventoryResourceEvent resourceEvent) { + var boundWithPart = resourceEventUtil.getNewFromInventoryEvent(resourceEvent, BoundWithPart.class); + rtacHoldingRepository.findByIdIdAndIdType(fromString(boundWithPart.getItemId()), ITEM) + .ifPresent(existingItemEntity -> { + var itemRtacHolding = existingItemEntity.getRtacHolding(); + if (StringUtils.equals(itemRtacHolding.getHoldingsId(), boundWithPart.getHoldingsRecordId())) { + return; + } + rtacHoldingRepository.findByIdIdAndIdType(fromString(boundWithPart.getHoldingsRecordId()), HOLDING) + .ifPresent(existingHoldingsEntity -> { + var holdingsRtacHolding = existingHoldingsEntity.getRtacHolding(); + var boundWithRtacHolding = rtacHoldingMappingService.mapForBoundWithItemTypeFrom(holdingsRtacHolding, + itemRtacHolding); + var boundWithRtacHoldingEntity = new RtacHoldingEntity(); + var boundWithRtacHoldingId = getRtacHoldingId(boundWithPart); + boundWithRtacHoldingEntity.setId(boundWithRtacHoldingId); + boundWithRtacHoldingEntity.setCreatedAt(existingItemEntity.getCreatedAt()); + boundWithRtacHoldingEntity.setRtacHolding(boundWithRtacHolding); + rtacHoldingRepository.save(boundWithRtacHoldingEntity); + }); + }); + } + + private RtacHoldingId getRtacHoldingId(BoundWithPart boundWithPart) { + var rtacHoldingId = new RtacHoldingId(); + rtacHoldingId.setId(fromString(boundWithPart.getItemId())); + rtacHoldingId.setInstanceId(fromString(boundWithPart.getInstanceId())); + rtacHoldingId.setType(ITEM); + return rtacHoldingId; + } + + @Override + public InventoryEventType getEventType() { + return InventoryEventType.CREATE; + } + + @Override + public InventoryEntityType getEntityType() { + return InventoryEntityType.ITEM_BOUND_WITH; + } +} diff --git a/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java new file mode 100644 index 0000000..64a61d9 --- /dev/null +++ b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java @@ -0,0 +1,49 @@ +package org.folio.rtaccache.service.handler.impl; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.folio.rtaccache.domain.RtacHoldingId; +import org.folio.rtaccache.domain.dto.BoundWithPart; +import org.folio.rtaccache.domain.dto.InventoryEntityType; +import org.folio.rtaccache.domain.dto.InventoryEventType; +import org.folio.rtaccache.domain.dto.InventoryResourceEvent; +import org.folio.rtaccache.domain.dto.RtacHolding.TypeEnum; +import org.folio.rtaccache.repository.RtacHoldingRepository; +import org.folio.rtaccache.service.handler.InventoryEventHandler; +import org.folio.rtaccache.util.ResourceEventUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ItemBoundWithDeleteEventHandler implements InventoryEventHandler { + + private final RtacHoldingRepository holdingRepository; + private final ResourceEventUtil resourceEventUtil; + + @Override + @Transactional + public void handle(InventoryResourceEvent resourceEvent) { + var boundWithPart = resourceEventUtil.getNewFromInventoryEvent(resourceEvent, BoundWithPart.class); + var rtacHoldingId = new RtacHoldingId(); + rtacHoldingId.setId(UUID.fromString(boundWithPart.getItemId())); + rtacHoldingId.setType(TypeEnum.ITEM); + rtacHoldingId.setInstanceId(UUID.fromString(boundWithPart.getInstanceId())); + holdingRepository.findById(rtacHoldingId).ifPresent(existingItemEntity -> { + var itemRtacHolding = existingItemEntity.getRtacHolding(); + if (itemRtacHolding.getIsBoundWith()) { + holdingRepository.deleteById(rtacHoldingId); + } + }); + } + + @Override + public InventoryEventType getEventType() { + return InventoryEventType.DELETE; + } + + @Override + public InventoryEntityType getEntityType() { + return InventoryEntityType.ITEM_BOUND_WITH; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c48d3aa..d96ea71 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -82,6 +82,10 @@ folio: concurrency: ${KAFKA_EVENTS_CONCURRENCY:2} topic-pattern: ${KAFKA_EVENTS_CONSUMER_PATTERN:${folio.environment}\.ALL\.inventory\.library} group-id: ${folio.environment}-mod-rtac-cache-library-group + bound-with: + concurrency: ${KAFKA_EVENTS_CONCURRENCY:2} + topic-pattern: ${KAFKA_EVENTS_CONSUMER_PATTERN:${folio.environment}\.ALL\.inventory\.bound-with} + group-id: ${folio.environment}-mod-rtac-cache-bound-with-group loan: concurrency: ${KAFKA_EVENTS_CONCURRENCY:2} topic-pattern: ${KAFKA_EVENTS_CONSUMER_PATTERN:${folio.environment}\.ALL\.circulation\.loan} diff --git a/src/main/resources/swagger.api/mod-rtac-cache.yaml b/src/main/resources/swagger.api/mod-rtac-cache.yaml index da4ba5a..46baf49 100644 --- a/src/main/resources/swagger.api/mod-rtac-cache.yaml +++ b/src/main/resources/swagger.api/mod-rtac-cache.yaml @@ -263,6 +263,10 @@ components: $ref: schemas/inventory/loclibs.json HoldingsNoteTypes: $ref: schemas/inventory/holdingsNoteTypes.json + BoundWithPart: + $ref: schemas/inventory/bound-with-part.json + BoundWithParts: + $ref: schemas/inventory/bound-with-parts.json Loans: $ref: schemas/circulation/loans.json Requests: diff --git a/src/main/resources/swagger.api/schemas/inventory/bound-with-part.json b/src/main/resources/swagger.api/schemas/inventory/bound-with-part.json new file mode 100644 index 0000000..ba20839 --- /dev/null +++ b/src/main/resources/swagger.api/schemas/inventory/bound-with-part.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Records the relationship between a part of a bound-with (a holdings-record) and the bound-with as a whole (the circulatable item)", + "type": "object", + "properties": { + + "id": { + "$ref": "../uuid.json" + }, + "instanceId" : { + "$ref": "../uuid.json" + }, + "holdingsRecordId": { + "$ref": "../uuid.json" + }, + "itemId" : { + "$ref": "../uuid.json" + }, + "metadata": { + "$ref": "../metadata.json" + } + }, + "additionalProperties": false, + "required": [ + "holdingsRecordId", + "itemId" + ] +} diff --git a/src/main/resources/swagger.api/schemas/inventory/bound-with-parts.json b/src/main/resources/swagger.api/schemas/inventory/bound-with-parts.json new file mode 100644 index 0000000..52117cf --- /dev/null +++ b/src/main/resources/swagger.api/schemas/inventory/bound-with-parts.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "A collection of parts (holdings-records) of one or more bound-with items", + "type": "object", + "properties": { + "boundWithParts": { + "description": "List of bound-with records", + "id": "boundWithPart", + "type": "array", + "items": { + "$ref": "bound-with-part.json" + } + }, + "totalRecords": { + "description": "Estimated or exact total number of records", + "type": "integer" + } + }, + "required": [ + "boundWithParts", + "totalRecords" + ] +} diff --git a/src/main/resources/swagger.api/schemas/kafka/inventoryEntityType.json b/src/main/resources/swagger.api/schemas/kafka/inventoryEntityType.json index 8743fa2..c76e90b 100644 --- a/src/main/resources/swagger.api/schemas/kafka/inventoryEntityType.json +++ b/src/main/resources/swagger.api/schemas/kafka/inventoryEntityType.json @@ -5,6 +5,7 @@ "enum": [ "HOLDINGS", "ITEM", + "ITEM_BOUND_WITH", "LOCATION", "LIBRARY" ] diff --git a/src/main/resources/swagger.api/schemas/rtacHolding.json b/src/main/resources/swagger.api/schemas/rtacHolding.json index a9c1f8b..aab2e14 100644 --- a/src/main/resources/swagger.api/schemas/rtacHolding.json +++ b/src/main/resources/swagger.api/schemas/rtacHolding.json @@ -167,6 +167,11 @@ "type": "string", "description": "A system generated normalization of the call number that allows for call number sorting in reports and search results" }, + "isBoundWith": { + "type": "boolean", + "default": false, + "description": "Indicates whether the record is part of a bound-with" + }, "createdAt": { "type": "string", "description": "Record creation date", diff --git a/src/test/java/org/folio/rtaccache/TestConstant.java b/src/test/java/org/folio/rtaccache/TestConstant.java index fa97ac6..3ef5f14 100644 --- a/src/test/java/org/folio/rtaccache/TestConstant.java +++ b/src/test/java/org/folio/rtaccache/TestConstant.java @@ -11,6 +11,7 @@ public class TestConstant { public static final String LOAN_TOPIC = "test.ALL.circulation.loan"; public static final String REQUEST_TOPIC = "test.ALL.circulation.request"; public static final String PIECE_TOPIC = "test.Default.ALL.ACQ_PIECE_CHANGED"; + public static final String BOUND_WITH_TOPIC = "test.ALL.inventory.bound-with"; public static final String EMPTY_INSTANCE_ID = "546dfd11-5c9f-4532-adbe-95b8e98683c9"; public static final String FAILING_INSTANCE_ID = "d1c1d890-1e0d-4bf0-8b02-9fb392461386"; public static final String ECS_INSTANCE1_ID = "1c3d3b16-89d5-44de-bdbe-d5a3b49a0087"; diff --git a/src/test/java/org/folio/rtaccache/configuration/KafkaIntegrationTestConfig.java b/src/test/java/org/folio/rtaccache/configuration/KafkaIntegrationTestConfig.java index e8beaea..bef73b6 100644 --- a/src/test/java/org/folio/rtaccache/configuration/KafkaIntegrationTestConfig.java +++ b/src/test/java/org/folio/rtaccache/configuration/KafkaIntegrationTestConfig.java @@ -1,5 +1,6 @@ package org.folio.rtaccache.configuration; +import static org.folio.rtaccache.TestConstant.BOUND_WITH_TOPIC; import static org.folio.rtaccache.TestConstant.HOLDINGS_TOPIC; import static org.folio.rtaccache.TestConstant.ITEM_TOPIC; import static org.folio.rtaccache.TestConstant.LIBRARY_TOPIC; @@ -60,4 +61,10 @@ public NewTopic libraryTopic() { .build(); } + @Bean + public NewTopic boundWithTopic() { + return TopicBuilder.name(BOUND_WITH_TOPIC) + .build(); + } + } diff --git a/src/test/java/org/folio/rtaccache/integration/KafkaMessageListenerIT.java b/src/test/java/org/folio/rtaccache/integration/KafkaMessageListenerIT.java index 37eeccb..3ba1fb2 100644 --- a/src/test/java/org/folio/rtaccache/integration/KafkaMessageListenerIT.java +++ b/src/test/java/org/folio/rtaccache/integration/KafkaMessageListenerIT.java @@ -61,7 +61,8 @@ class KafkaMessageListenerIT extends BaseIntegrationTest { private static final String HOLDINGS_ID_2 = "48525495-05b0-488e-a0c5-0f3ec5c7a0f2"; private static final String ITEM_ID = "522d41d3-0e04-416d-9f52-90ac67685a78"; private static final String PIECE_ID = "d892d70b-96be-4e5b-ab11-05839eb5df40"; - private static final String INSTANCE_ID = "843b368d-411c-4dce-bd64-99afc53f508d"; + private static final String INSTANCE_ID_1 = "843b368d-411c-4dce-bd64-99afc53f508d"; + private static final String INSTANCE_ID_2 = "4b0559ef-6738-4d51-98fb-16db8cbf935e"; private static final String CREATE_HOLDINGS_EVENT_PATH = "__files/kafka-events/create-holdings-event.json"; private static final String DELETE_HOLDINGS_EVENT_PATH = "__files/kafka-events/delete-holdings-event.json"; @@ -82,6 +83,8 @@ class KafkaMessageListenerIT extends BaseIntegrationTest { private static final String CREATE_LIBRARY_EVENT_PATH = "__files/kafka-events/create-library-event.json"; private static final String DELETE_LIBRARY_EVENT_PATH = "__files/kafka-events/delete-library-event.json"; private static final String UPDATE_LIBRARY_EVENT_PATH = "__files/kafka-events/update-library-event.json"; + private static final String CREATE_BOUND_WITH_EVENT_PATH = "__files/kafka-events/create-bound-with-event.json"; + private static final String DELETE_BOUND_WITH_EVENT_PATH = "__files/kafka-events/delete-bound-with-event.json"; private static final String OLD_CALL_NUMBER = "OLD-CALL-123"; private static final String NEW_CALL_NUMBER = "NEW-CALL-456"; @@ -552,32 +555,75 @@ void shouldClearLibraryCache_whenLibraryDeleteEventIsSent() throws JsonProcessin } } + @Test + @Order(22) + void shouldCreateRtacHolding_withItemType_whenBoundWithCreateEventIsSent() throws JsonProcessingException { + try (var ignored = new FolioExecutionContextSetter(folioExecutionContext())) { + // Given + createExistingRtacHoldingEntity(ITEM_ID, TypeEnum.ITEM); + createExistingRtacHoldingEntity(HOLDINGS_ID_2, TypeEnum.HOLDING); + var event = loadInventoryResourceEvent(CREATE_BOUND_WITH_EVENT_PATH); + // When + sendBoundWithEvent(event); + // Then + await().atMost(Duration.ofSeconds(50)).untilAsserted(() -> { + var rtacHoldingId = new RtacHoldingId(UUID.fromString(INSTANCE_ID_2), TypeEnum.ITEM, UUID.fromString(ITEM_ID)); + var holding = holdingRepository.findById(rtacHoldingId); + assertThat(holding).isPresent(); + assertThat(holding.get().getRtacHolding().getId()).isEqualTo(ITEM_ID); + assertThat(holding.get().getRtacHolding().getIsBoundWith()).isEqualTo(true); + }); + } + } + + @Test + @Order(23) + void shouldDeleteRtacHolding_withItemType_whenBoundWithDeleteEventIsSent() throws JsonProcessingException { + try (var ignored = new FolioExecutionContextSetter(folioExecutionContext())) { + // Given + createExistingRtacHoldingEntity(ITEM_ID, TypeEnum.ITEM, true); + var event = loadInventoryResourceEvent(DELETE_BOUND_WITH_EVENT_PATH); + // When + sendBoundWithEvent(event); + // Then + await().atMost(Duration.ofSeconds(50)).untilAsserted(() -> { + var count = holdingRepository.count(); + assertThat(count).isZero(); + }); + } + } + private void createExistingRtacHoldingEntity(String id, TypeEnum type) { + createExistingRtacHoldingEntity(id, type, false); + } + + private void createExistingRtacHoldingEntity(String id, TypeEnum type, boolean isBoundWith) { RtacHoldingEntity entity = new RtacHoldingEntity(); RtacHoldingId rtacHoldingId = new RtacHoldingId(); rtacHoldingId.setId(UUID.fromString(id)); - rtacHoldingId.setInstanceId(UUID.fromString(INSTANCE_ID)); + rtacHoldingId.setInstanceId(UUID.fromString(INSTANCE_ID_1)); rtacHoldingId.setType(type); entity.setId(rtacHoldingId); - var rtacHolding = createRtacHolding(id, type); + var rtacHolding = createRtacHolding(id, type, isBoundWith); entity.setRtacHolding(rtacHolding); entity.setCreatedAt(Instant.now()); holdingRepository.save(entity); } - private RtacHolding createRtacHolding(String id, TypeEnum type) { + private RtacHolding createRtacHolding(String id, TypeEnum type, boolean isBoundWith) { var rtacHolding = new RtacHolding(); rtacHolding.setCallNumber(OLD_CALL_NUMBER); rtacHolding.setId(id); - rtacHolding.setInstanceId(INSTANCE_ID); + rtacHolding.setInstanceId(INSTANCE_ID_1); rtacHolding.setHoldingsId(HOLDINGS_ID_1); rtacHolding.setType(type); rtacHolding.setStatus(OLD_STATUS); rtacHolding.setHoldingsCopyNumber(OLD_HOLDINGS_COPY_NUMBER); rtacHolding.setTotalHoldRequests(1); + rtacHolding.setIsBoundWith(isBoundWith); var location = new RtacHoldingLocation(); location.setId(OLD_LOCATION_ID); @@ -641,6 +687,11 @@ private void sendPieceKafkaMessage(PieceResourceEvent event, String id) { pieceKafkaTemplate.send(pieceRecord); } + private void sendBoundWithEvent(InventoryResourceEvent event) { + var boundWithRecord = new ProducerRecord<>(TestConstant.BOUND_WITH_TOPIC, ITEM_ID, event); + inventoryKafkaTemplate.send(boundWithRecord); + } + private FolioExecutionContext folioExecutionContext() { var headersMap = (Map>) (Map) Map.of( XOkapiHeaders.TENANT, Lists.newArrayList(TEST_TENANT), diff --git a/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java b/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java index c29ed3e..9e473ee 100644 --- a/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java +++ b/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java @@ -25,8 +25,10 @@ class RtacCacheGenerationServiceIT extends BaseIntegrationTest { private RtacHoldingRepository rtacHoldingRepository; @MockitoSpyBean private FolioExecutionContext folioExecutionContext; - private static final String INSTANCE_ID = "4de861ab-af9e-4247-bc16-c547d982eb5d"; + private static final String INSTANCE_ID_1 = "4de861ab-af9e-4247-bc16-c547d982eb5d"; + private static final String INSTANCE_ID_2 = "5dbab2d4-42f6-47e0-b0c6-023040bd19ff"; private static final String ITEM_WITH_LOANS_AND_REQUESTS_ID = "9a772288-ead3-4033-b07b-87eff643710f"; + private static final String BOUND_WITH_ITEM_ID = "02b9b326-903b-41d7-b947-fc809e9d38c1"; private static final String PIECE_ID = "aaf7be29-a8cc-4b0a-9975-bf39e9a71696"; @AfterEach @@ -39,10 +41,10 @@ void generateRtacCache_shouldFetchAndProcessRtacData() { when(folioExecutionContext.getTenantId()).thenReturn(TestConstant.TEST_TENANT); when(folioExecutionContext.getOkapiUrl()).thenReturn(WIRE_MOCK.baseUrl()); - var future = rtacCacheGenerationService.generateRtacCache(INSTANCE_ID); + var future = rtacCacheGenerationService.generateRtacCache(INSTANCE_ID_1); future.join(); - var holdings = rtacHoldingRepository.findAllByIdInstanceId(UUID.fromString(INSTANCE_ID), PageRequest.of(0, 50)); + var holdings = rtacHoldingRepository.findAllByIdInstanceId(UUID.fromString(INSTANCE_ID_1), PageRequest.of(0, 50)); var itemWithLoans = holdings.get() .filter(entity -> entity.getRtacHolding().getDueDate() != null).findFirst(); var piece = holdings.get() @@ -61,4 +63,19 @@ void generateRtacCache_shouldFetchAndProcessRtacData() { assertTrue(holdings.get().allMatch(RtacHoldingEntity::isShared)); } + @Test + void generateRtacCache_shouldProcessBoundWithItem() { + when(folioExecutionContext.getTenantId()).thenReturn(TestConstant.TEST_TENANT); + when(folioExecutionContext.getOkapiUrl()).thenReturn(WIRE_MOCK.baseUrl()); + + var future = rtacCacheGenerationService.generateRtacCache(INSTANCE_ID_2); + future.join(); + + var holdings = rtacHoldingRepository.findAllByIdInstanceId(UUID.fromString(INSTANCE_ID_2), PageRequest.of(0, 50)); + assertEquals(1, holdings.getTotalElements()); + var rtacHoldingEntity = holdings.get().findFirst().get(); + assertEquals(BOUND_WITH_ITEM_ID, rtacHoldingEntity.getRtacHolding().getId()); + assertTrue(rtacHoldingEntity.getRtacHolding().getIsBoundWith()); + } + } diff --git a/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java new file mode 100644 index 0000000..78aca59 --- /dev/null +++ b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java @@ -0,0 +1,144 @@ +package org.folio.rtaccache.service.handler.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.folio.rtaccache.domain.RtacHoldingEntity; +import org.folio.rtaccache.domain.RtacHoldingId; +import org.folio.rtaccache.domain.dto.BoundWithPart; +import org.folio.rtaccache.domain.dto.InventoryEventType; +import org.folio.rtaccache.domain.dto.InventoryResourceEvent; +import org.folio.rtaccache.domain.dto.RtacHolding; +import org.folio.rtaccache.domain.dto.RtacHolding.TypeEnum; +import org.folio.rtaccache.repository.RtacHoldingRepository; +import org.folio.rtaccache.service.RtacHoldingMappingService; +import org.folio.rtaccache.util.ResourceEventUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ItemBoundWithCreateEventHandlerTest { + + private static final String INSTANCE_ID = UUID.randomUUID().toString(); + private static final String OLD_HOLDINGS_ID = UUID.randomUUID().toString(); + private static final String NEW_HOLDINGS_ID = UUID.randomUUID().toString(); + private static final String ITEM_ID = UUID.randomUUID().toString(); + + @InjectMocks + ItemBoundWithCreateEventHandler handler; + + @Mock + RtacHoldingRepository holdingRepository; + @Mock + RtacHoldingMappingService mappingService; + @Mock + ResourceEventUtil resourceEventUtil; + + @Test + void itemBoundWithCreate_shouldSaveEntity_whenHoldingsChangedAndHoldingsExists() { + var event = setUpEventWith(boundWithPart(INSTANCE_ID, NEW_HOLDINGS_ID, ITEM_ID)); + var existingItemEntity = setUpExistingItemEntity(INSTANCE_ID, ITEM_ID, OLD_HOLDINGS_ID); + setUpExistingHoldingsEntity(INSTANCE_ID, NEW_HOLDINGS_ID); + setUpMappingServiceForBoundWith(ITEM_ID, INSTANCE_ID, NEW_HOLDINGS_ID); + + handler.handle(event); + + var captor = ArgumentCaptor.forClass(RtacHoldingEntity.class); + verify(holdingRepository).save(captor.capture()); + var saved = captor.getValue(); + assertEquals(UUID.fromString(ITEM_ID), saved.getId().getId()); + assertEquals(UUID.fromString(INSTANCE_ID), saved.getId().getInstanceId()); + assertEquals(TypeEnum.ITEM, saved.getId().getType()); + assertEquals(existingItemEntity.getCreatedAt(), saved.getCreatedAt()); + } + + @Test + void itemBoundWithCreate_shouldNotSave_whenItemEntityNotFound() { + var event = new InventoryResourceEvent() + .type(InventoryEventType.CREATE) + ._new(boundWithPart(INSTANCE_ID, NEW_HOLDINGS_ID, ITEM_ID)); + when(resourceEventUtil.getNewFromInventoryEvent(event, BoundWithPart.class)) + .thenReturn(boundWithPart(INSTANCE_ID, NEW_HOLDINGS_ID, ITEM_ID)); + when(holdingRepository.findByIdId(UUID.fromString(ITEM_ID))) + .thenReturn(Optional.empty()); + + handler.handle(event); + + verify(holdingRepository, never()).findByIdIdAndIdType(any(UUID.class), any(TypeEnum.class)); + verify(holdingRepository, never()).save(any(RtacHoldingEntity.class)); + } + + @Test + void itemBoundWithCreate_shouldNotSave_whenHoldingsIdIsSame() { + var event = new InventoryResourceEvent() + .type(InventoryEventType.CREATE) + ._new(boundWithPart(INSTANCE_ID, OLD_HOLDINGS_ID, ITEM_ID)); + when(resourceEventUtil.getNewFromInventoryEvent(event, BoundWithPart.class)) + .thenReturn(boundWithPart(INSTANCE_ID, OLD_HOLDINGS_ID, ITEM_ID)); + setUpExistingItemEntity(INSTANCE_ID, ITEM_ID, OLD_HOLDINGS_ID); + + handler.handle(event); + + verify(holdingRepository, never()).findByIdIdAndIdType(any(UUID.class), any(TypeEnum.class)); + verify(holdingRepository, never()).save(any(RtacHoldingEntity.class)); + } + + private InventoryResourceEvent setUpEventWith(BoundWithPart part) { + var event = new InventoryResourceEvent().type(InventoryEventType.CREATE)._new(part); + when(resourceEventUtil.getNewFromInventoryEvent(event, BoundWithPart.class)).thenReturn(part); + return event; + } + + private RtacHoldingEntity setUpExistingItemEntity(String instanceId, String itemId, String holdingsId) { + var entity = new RtacHoldingEntity( + new RtacHoldingId(UUID.fromString(instanceId), TypeEnum.ITEM, UUID.fromString(itemId)), + holdingMapped(TypeEnum.ITEM, itemId, instanceId, holdingsId), + Instant.now() + ); + when(holdingRepository.findByIdId(UUID.fromString(itemId))).thenReturn(Optional.of(entity)); + return entity; + } + + private void setUpExistingHoldingsEntity(String instanceId, String holdingsId) { + var entity = new RtacHoldingEntity( + new RtacHoldingId(UUID.fromString(instanceId), TypeEnum.HOLDING, UUID.fromString(holdingsId)), + holdingMapped(TypeEnum.HOLDING, holdingsId, instanceId, holdingsId), + Instant.now() + ); + when(holdingRepository.findByIdIdAndIdType(UUID.fromString(holdingsId), TypeEnum.HOLDING)).thenReturn(Optional.of(entity)); + } + + private void setUpMappingServiceForBoundWith(String itemId, String instanceId, String newHoldingsId) { + var mappedBoundWithItem = holdingMapped(TypeEnum.ITEM, itemId, instanceId, newHoldingsId); + when(mappingService.mapForBoundWithItemTypeFrom(any(RtacHolding.class), any(RtacHolding.class))) + .thenReturn(mappedBoundWithItem); + } + + private BoundWithPart boundWithPart(String instanceId, String holdingsId, String itemId) { + var bw = new BoundWithPart(); + bw.setInstanceId(instanceId); + bw.setHoldingsRecordId(holdingsId); + bw.setItemId(itemId); + return bw; + } + + private RtacHolding holdingMapped(TypeEnum type, String id, String instanceId, String holdingsId) { + var rh = new RtacHolding(); + rh.setType(type); + rh.setId(id); + rh.setInstanceId(instanceId); + rh.setHoldingsId(holdingsId); + rh.setStatus("Available"); + return rh; + } +} diff --git a/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java new file mode 100644 index 0000000..90d173b --- /dev/null +++ b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java @@ -0,0 +1,95 @@ +package org.folio.rtaccache.service.handler.impl; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import org.folio.rtaccache.domain.RtacHoldingEntity; +import org.folio.rtaccache.domain.RtacHoldingId; +import org.folio.rtaccache.domain.dto.BoundWithPart; +import org.folio.rtaccache.domain.dto.InventoryEventType; +import org.folio.rtaccache.domain.dto.InventoryResourceEvent; +import org.folio.rtaccache.domain.dto.RtacHolding; +import org.folio.rtaccache.domain.dto.RtacHolding.TypeEnum; +import org.folio.rtaccache.repository.RtacHoldingRepository; +import org.folio.rtaccache.util.ResourceEventUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ItemBoundWithDeleteEventHandlerTest { + + private static final String INSTANCE_ID = UUID.randomUUID().toString(); + private static final String ITEM_ID = UUID.randomUUID().toString(); + + @InjectMocks + ItemBoundWithDeleteEventHandler handler; + + @Mock + RtacHoldingRepository holdingRepository; + @Mock + ResourceEventUtil resourceEventUtil; + + @Test + void itemBoundWithDelete_shouldDelete_whenItemIsBoundWith() { + var event = setUpEventWith(boundWithPart(INSTANCE_ID, ITEM_ID)); + var existingEntity = setUpExistingItemEntity(INSTANCE_ID, ITEM_ID, true); + + handler.handle(event); + + verify(holdingRepository).deleteById(existingEntity.getId()); + } + + @Test + void itemBoundWithDelete_shouldNotDelete_whenItemEntityNotFound() { + var event = setUpEventWith(boundWithPart(INSTANCE_ID, ITEM_ID)); + when(holdingRepository.findById(any(RtacHoldingId.class))).thenReturn(Optional.empty()); + + handler.handle(event); + + verify(holdingRepository, never()).deleteById(any(RtacHoldingId.class)); + } + + @Test + void itemBoundWithDelete_shouldNotDelete_whenItemIsNotBoundWith() { + var event = setUpEventWith(boundWithPart(INSTANCE_ID, ITEM_ID)); + setUpExistingItemEntity(INSTANCE_ID, ITEM_ID, false); + + handler.handle(event); + + verify(holdingRepository, never()).deleteById(any(RtacHoldingId.class)); + } + + private InventoryResourceEvent setUpEventWith(BoundWithPart part) { + var event = new InventoryResourceEvent().type(InventoryEventType.DELETE)._new(part); + when(resourceEventUtil.getNewFromInventoryEvent(event, BoundWithPart.class)).thenReturn(part); + return event; + } + + private RtacHoldingEntity setUpExistingItemEntity(String instanceId, String itemId, boolean isBoundWith) { + var id = new RtacHoldingId(UUID.fromString(instanceId), TypeEnum.ITEM, UUID.fromString(itemId)); + var holding = new RtacHolding(); + holding.setType(TypeEnum.ITEM); + holding.setId(itemId); + holding.setInstanceId(instanceId); + holding.setIsBoundWith(isBoundWith); + var entity = new RtacHoldingEntity(); + entity.setId(id); + entity.setRtacHolding(holding); + when(holdingRepository.findById(id)).thenReturn(Optional.of(entity)); + return entity; + } + + private BoundWithPart boundWithPart(String instanceId, String itemId) { + var bw = new BoundWithPart(); + bw.setInstanceId(instanceId); + bw.setItemId(itemId); + return bw; + } +} diff --git a/src/test/resources/__files/cache-generation/bound-with-holdings-record-response.json b/src/test/resources/__files/cache-generation/bound-with-holdings-record-response.json new file mode 100644 index 0000000..be11806 --- /dev/null +++ b/src/test/resources/__files/cache-generation/bound-with-holdings-record-response.json @@ -0,0 +1,35 @@ +{ + "holdingsRecords": [ + { + "holdingsItems": [], + "bareHoldingsItems": [], + "id": "e42b81ff-629a-4b76-9b6b-7cb92c9ea59e", + "_version": 1, + "sourceId": "f32d531e-df79-46b3-8932-cdd35f7a2264", + "hrid": "colho00000683924", + "formerIds": [], + "instanceId": "5dbab2d4-42f6-47e0-b0c6-023040bd19ff", + "permanentLocationId": "773e5ce3-c226-4818-a78d-4de7ee0c9418", + "effectiveLocationId": "773e5ce3-c226-4818-a78d-4de7ee0c9418", + "electronicAccess": [], + "administrativeNotes": [], + "notes": [], + "holdingsStatements": [], + "holdingsStatementsForIndexes": [], + "holdingsStatementsForSupplements": [], + "statisticalCodeIds": [], + "metadata": { + "createdDate": "2025-11-02T11:17:50.138+00:00", + "createdByUserId": "9f9d1c46-52e1-4bb7-9c6c-56e6bb945c42", + "updatedDate": "2025-11-02T11:17:50.138+00:00", + "updatedByUserId": "9f9d1c46-52e1-4bb7-9c6c-56e6bb945c42" + } + } + ], + "totalRecords": 1, + "resultInfo": { + "totalRecords": 1, + "facets": [], + "diagnostics": [] + } +} diff --git a/src/test/resources/__files/cache-generation/bound-with-response.json b/src/test/resources/__files/cache-generation/bound-with-response.json new file mode 100644 index 0000000..8160f17 --- /dev/null +++ b/src/test/resources/__files/cache-generation/bound-with-response.json @@ -0,0 +1,14 @@ +{ + "boundWithParts": [ + { + "id": "60e41ef7-c595-40d9-a3a3-e34c7b301c9c", + "holdingsRecordId": "e42b81ff-629a-4b76-9b6b-7cb92c9ea59e", + "itemId": "02b9b326-903b-41d7-b947-fc809e9d38c1", + "metadata": { + "createdDate": "2025-02-14T09:44:50.769+00:00", + "updatedDate": "2025-02-14T09:44:50.769+00:00" + } + } + ], + "totalRecords": 1 +} diff --git a/src/test/resources/__files/cache-generation/empty-bound-with-response.json b/src/test/resources/__files/cache-generation/empty-bound-with-response.json new file mode 100644 index 0000000..316bd0a --- /dev/null +++ b/src/test/resources/__files/cache-generation/empty-bound-with-response.json @@ -0,0 +1,4 @@ +{ + "boundWithParts": [], + "totalRecords": 0 +} diff --git a/src/test/resources/__files/cache-generation/holding2-empty-pieces-response.json b/src/test/resources/__files/cache-generation/empty-pieces-response.json similarity index 100% rename from src/test/resources/__files/cache-generation/holding2-empty-pieces-response.json rename to src/test/resources/__files/cache-generation/empty-pieces-response.json diff --git a/src/test/resources/__files/cache-generation/holding3-items-response.json b/src/test/resources/__files/cache-generation/holding3-items-response.json new file mode 100644 index 0000000..c28c7ec --- /dev/null +++ b/src/test/resources/__files/cache-generation/holding3-items-response.json @@ -0,0 +1,9 @@ +{ + "items": [], + "totalRecords": 0, + "resultInfo": { + "totalRecords": 0, + "facets": [], + "diagnostics": [] + } +} diff --git a/src/test/resources/__files/cache-generation/holding4-items-response.json b/src/test/resources/__files/cache-generation/holding4-items-response.json new file mode 100644 index 0000000..3df94ce --- /dev/null +++ b/src/test/resources/__files/cache-generation/holding4-items-response.json @@ -0,0 +1,42 @@ +{ + "items": [ + { + "id": "02b9b326-903b-41d7-b947-fc809e9d38c1", + "_version": 1, + "hrid": "colit00001478541", + "holdingsRecordId": "dfec0729-ded5-4c81-8daf-9ebd07d3d8ec", + "formerIds": [], + "barcode": "470789994194", + "effectiveShelvingOrder": "QA76.73.J38 G67 2023", + "itemLevelCallNumber": "QA76.73.J38 G67 2023", + "effectiveCallNumberComponents": { + "callNumber": "QA76.73.J38 G67 2023" + }, + "yearCaption": [], + "administrativeNotes": [], + "notes": [], + "circulationNotes": [], + "status": { + "name": "Available", + "date": "2025-11-02T11:18:03.683+00:00" + }, + "materialTypeId": "1a54b431-2e4f-452d-9cae-9cee66c9a892", + "permanentLoanTypeId": "2b94c631-fca9-4892-a730-03ee529ffe27", + "effectiveLocationId": "2d65e095-5750-56ee-ca60-5584eg7ce39b", + "electronicAccess": [], + "statisticalCodeIds": [], + "metadata": { + "createdDate": "2025-11-02T11:18:03.683+00:00", + "createdByUserId": "9f9d1c46-52e1-4bb7-9c6c-56e6bb945c42", + "updatedDate": "2025-11-02T11:18:03.683+00:00", + "updatedByUserId": "9f9d1c46-52e1-4bb7-9c6c-56e6bb945c42" + } + } + ], + "totalRecords": 1, + "resultInfo": { + "totalRecords": 1, + "facets": [], + "diagnostics": [] + } +} diff --git a/src/test/resources/__files/kafka-events/create-bound-with-event.json b/src/test/resources/__files/kafka-events/create-bound-with-event.json new file mode 100644 index 0000000..958ed16 --- /dev/null +++ b/src/test/resources/__files/kafka-events/create-bound-with-event.json @@ -0,0 +1,16 @@ +{ + "new": { + "instanceId": "4b0559ef-6738-4d51-98fb-16db8cbf935e", + "id": "6fc15a10-aab1-4726-9d5a-ee51cdf80606", + "holdingsRecordId": "48525495-05b0-488e-a0c5-0f3ec5c7a0f2", + "itemId": "522d41d3-0e04-416d-9f52-90ac67685a78", + "metadata": { + "createdDate": "2025-12-22T12:48:24.652+00:00", + "updatedDate": "2025-12-22T12:48:24.652+00:00" + } + }, + "type": "CREATE", + "tenant": "testTenant", + "eventId": "4b6d1751-e4b7-4b58-bca4-faab359035f4", + "eventTs": 1766407704686 +} diff --git a/src/test/resources/__files/kafka-events/delete-bound-with-event.json b/src/test/resources/__files/kafka-events/delete-bound-with-event.json new file mode 100644 index 0000000..c1d9c15 --- /dev/null +++ b/src/test/resources/__files/kafka-events/delete-bound-with-event.json @@ -0,0 +1,16 @@ +{ + "new": { + "instanceId": "843b368d-411c-4dce-bd64-99afc53f508d", + "id": "6fc15a10-aab1-4726-9d5a-ee51cdf80606", + "holdingsRecordId": "55fa3746-8176-49c5-9809-b29dd7bb9b47", + "itemId": "522d41d3-0e04-416d-9f52-90ac67685a78", + "metadata": { + "createdDate": "2025-12-22T12:48:24.652+00:00", + "updatedDate": "2025-12-22T12:48:24.652+00:00" + } + }, + "type": "DELETE", + "tenant": "testTenant", + "eventId": "4b6d1751-e4b7-4b58-bca4-faab359035f4", + "eventTs": 1766407704686 +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1faa8e3..855fecd 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -40,22 +40,6 @@ folio: validation: enabled: false kafka: - topics: - - name: inventory.holdings-record - numPartitions: 1 - replicationFactor: 1 - - name: inventory.item - numPartitions: 1 - replicationFactor: 1 - - name: circulation.loan - numPartitions: 1 - replicationFactor: 1 - - name: circulation.request - numPartitions: 1 - replicationFactor: 1 - - name: ACQ_PIECE_CHANGED - numPartitions: 1 - replicationFactor: 1 listener: holdings-record: concurrency: 1 @@ -73,6 +57,10 @@ folio: concurrency: 1 topic-pattern: ${folio.environment}\.ALL\.inventory\.library group-id: ${folio.environment}-mod-rtac-cache-library-group + bound-with: + concurrency: 1 + topic-pattern: ${folio.environment}\.ALL\.inventory\.bound-with + group-id: ${folio.environment}-mod-rtac-cache-bound-with-group loan: concurrency: 1 topic-pattern: ${folio.environment}\.ALL\.circulation\.loan diff --git a/src/test/resources/mappings/cache-generation.json b/src/test/resources/mappings/cache-generation.json index c8b1fc8..010714a 100644 --- a/src/test/resources/mappings/cache-generation.json +++ b/src/test/resources/mappings/cache-generation.json @@ -34,6 +34,24 @@ "bodyFileName": "cache-generation/holdings-records-response.json" } }, + { + "request": { + "method": "POST", + "urlPath": "/holdings-storage/holdings/retrieve", + "bodyPatterns": [ + { + "contains": "instanceId==5dbab2d4-42f6-47e0-b0c6-023040bd19ff" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/bound-with-holdings-record-response.json" + } + }, { "request": { "method": "POST", @@ -70,6 +88,42 @@ "bodyFileName": "cache-generation/holding2-items-response.json" } }, + { + "request": { + "method": "POST", + "urlPath": "/item-storage/items/retrieve", + "bodyPatterns": [ + { + "contains": "holdingsRecordId==e42b81ff-629a-4b76-9b6b-7cb92c9ea59e" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/holding3-items-response.json" + } + }, + { + "request": { + "method": "POST", + "urlPath": "/item-storage/items/retrieve", + "bodyPatterns": [ + { + "contains": "id==02b9b326-903b-41d7-b947-fc809e9d38c1" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/holding4-items-response.json" + } + }, { "request": { "method": "GET", @@ -99,7 +153,23 @@ "headers": { "Content-Type": "application/json" }, - "bodyFileName": "cache-generation/holding2-empty-pieces-response.json" + "bodyFileName": "cache-generation/empty-pieces-response.json" + } + }, + { + "request": { + "method": "GET", + "urlPath": "/orders/pieces", + "queryParameters": { + "query": { "contains": "holdingId==(e42b81ff-629a-4b76-9b6b-7cb92c9ea59e)" } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/empty-pieces-response.json" } }, { @@ -133,6 +203,54 @@ }, "bodyFileName": "cache-generation/holding1-item1-requests-response.json" } + }, + { + "request": { + "method": "GET", + "urlPath": "/inventory-storage/bound-with-parts", + "queryParameters": { + "query": { "contains": "holdingsRecordId==f34d3c43-695d-468a-8b05-93a52f2c39a3" } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/empty-bound-with-response.json" + } + }, + { + "request": { + "method": "GET", + "urlPath": "/inventory-storage/bound-with-parts", + "queryParameters": { + "query": { "contains": "holdingsRecordId==fff4579a-be80-464e-9307-bd1c6d5e1ce" } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/empty-bound-with-response.json" + } + }, + { + "request": { + "method": "GET", + "urlPath": "/inventory-storage/bound-with-parts", + "queryParameters": { + "query": { "contains": "holdingsRecordId==e42b81ff-629a-4b76-9b6b-7cb92c9ea59e" } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/bound-with-response.json" + } } ] } From 69780645c70a533cd3bd1c9d3aafff59e74380fe Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Wed, 24 Dec 2025 14:50:17 +0200 Subject: [PATCH 2/6] Add bound-with permission and interface version to ModuleDescriptor --- descriptors/ModuleDescriptor-template.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index aa2fec9..ab16a27 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -19,6 +19,7 @@ "inventory-storage.material-types.collection.get", "inventory-storage.loan-types.collection.get", "inventory-storage.holdings-note-types.collection.get", + "inventory-storage.bound-with-parts.collection.get", "circulation-storage.loans.collection.get", "circulation.requests.collection.get", "orders.pieces.collection.get", @@ -39,6 +40,7 @@ "inventory-storage.material-types.collection.get", "inventory-storage.loan-types.collection.get", "inventory-storage.holdings-note-types.collection.get", + "inventory-storage.bound-with-parts.collection.get", "circulation-storage.loans.collection.get", "circulation.requests.collection.get", "orders.pieces.collection.get", @@ -63,6 +65,7 @@ "inventory-storage.material-types.collection.get", "inventory-storage.loan-types.collection.get", "inventory-storage.holdings-note-types.collection.get", + "inventory-storage.bound-with-parts.collection.get", "circulation-storage.loans.collection.get", "circulation.requests.collection.get", "orders.pieces.collection.get", @@ -154,6 +157,10 @@ { "id": "settings", "version": "1.1 1.2" + }, + { + "id": "bound-with-parts-storage", + "version": "2.0" } ], "optional": [ @@ -225,6 +232,7 @@ "inventory-storage.holdings.retrieve.collection.post", "inventory-storage.location-units.libraries.collection.get", "inventory-storage.locations.collection.get", + "inventory-storage.bound-with-parts.collection.get", "inventory-storage.material-types.collection.get", "inventory-storage.loan-types.collection.get", "inventory-storage.holdings-note-types.collection.get", From 55e46bfa2e23ab295f4bab1712328011ccebcd9d Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Wed, 24 Dec 2025 17:50:42 +0200 Subject: [PATCH 3/6] Fix tests after resolving conflicts --- .../service/RtacCacheGenerationServiceIT.java | 9 +++++++-- .../ItemBoundWithCreateEventHandlerTest.java | 11 ++++++----- ...e-response.json => instance1-response.json} | 0 .../cache-generation/instance2-response.json | 10 ++++++++++ .../resources/mappings/cache-generation.json | 18 +++++++++++++++++- 5 files changed, 40 insertions(+), 8 deletions(-) rename src/test/resources/__files/cache-generation/{instance-response.json => instance1-response.json} (100%) create mode 100644 src/test/resources/__files/cache-generation/instance2-response.json diff --git a/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java b/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java index 9e473ee..1560bd9 100644 --- a/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java +++ b/src/test/java/org/folio/rtaccache/service/RtacCacheGenerationServiceIT.java @@ -1,6 +1,7 @@ package org.folio.rtaccache.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; @@ -72,10 +73,14 @@ void generateRtacCache_shouldProcessBoundWithItem() { future.join(); var holdings = rtacHoldingRepository.findAllByIdInstanceId(UUID.fromString(INSTANCE_ID_2), PageRequest.of(0, 50)); - assertEquals(1, holdings.getTotalElements()); - var rtacHoldingEntity = holdings.get().findFirst().get(); + assertEquals(2, holdings.getTotalElements()); + var rtacHoldingEntity = holdings.get() + .filter(entity -> entity.getRtacHolding().getType() == TypeEnum.ITEM) + .findFirst() + .get(); assertEquals(BOUND_WITH_ITEM_ID, rtacHoldingEntity.getRtacHolding().getId()); assertTrue(rtacHoldingEntity.getRtacHolding().getIsBoundWith()); + assertFalse(rtacHoldingEntity.isShared()); } } diff --git a/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java index 78aca59..12922f9 100644 --- a/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java +++ b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithCreateEventHandlerTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -69,12 +70,10 @@ void itemBoundWithCreate_shouldNotSave_whenItemEntityNotFound() { ._new(boundWithPart(INSTANCE_ID, NEW_HOLDINGS_ID, ITEM_ID)); when(resourceEventUtil.getNewFromInventoryEvent(event, BoundWithPart.class)) .thenReturn(boundWithPart(INSTANCE_ID, NEW_HOLDINGS_ID, ITEM_ID)); - when(holdingRepository.findByIdId(UUID.fromString(ITEM_ID))) - .thenReturn(Optional.empty()); handler.handle(event); - verify(holdingRepository, never()).findByIdIdAndIdType(any(UUID.class), any(TypeEnum.class)); + verify(holdingRepository, never()).findByIdIdAndIdType(any(UUID.class), eq(TypeEnum.HOLDING)); verify(holdingRepository, never()).save(any(RtacHoldingEntity.class)); } @@ -89,7 +88,7 @@ void itemBoundWithCreate_shouldNotSave_whenHoldingsIdIsSame() { handler.handle(event); - verify(holdingRepository, never()).findByIdIdAndIdType(any(UUID.class), any(TypeEnum.class)); + verify(holdingRepository, never()).findByIdIdAndIdType(any(UUID.class), eq(TypeEnum.HOLDING)); verify(holdingRepository, never()).save(any(RtacHoldingEntity.class)); } @@ -102,16 +101,18 @@ private InventoryResourceEvent setUpEventWith(BoundWithPart part) { private RtacHoldingEntity setUpExistingItemEntity(String instanceId, String itemId, String holdingsId) { var entity = new RtacHoldingEntity( new RtacHoldingId(UUID.fromString(instanceId), TypeEnum.ITEM, UUID.fromString(itemId)), + false, holdingMapped(TypeEnum.ITEM, itemId, instanceId, holdingsId), Instant.now() ); - when(holdingRepository.findByIdId(UUID.fromString(itemId))).thenReturn(Optional.of(entity)); + when(holdingRepository.findByIdIdAndIdType(UUID.fromString(itemId), TypeEnum.ITEM)).thenReturn(Optional.of(entity)); return entity; } private void setUpExistingHoldingsEntity(String instanceId, String holdingsId) { var entity = new RtacHoldingEntity( new RtacHoldingId(UUID.fromString(instanceId), TypeEnum.HOLDING, UUID.fromString(holdingsId)), + false, holdingMapped(TypeEnum.HOLDING, holdingsId, instanceId, holdingsId), Instant.now() ); diff --git a/src/test/resources/__files/cache-generation/instance-response.json b/src/test/resources/__files/cache-generation/instance1-response.json similarity index 100% rename from src/test/resources/__files/cache-generation/instance-response.json rename to src/test/resources/__files/cache-generation/instance1-response.json diff --git a/src/test/resources/__files/cache-generation/instance2-response.json b/src/test/resources/__files/cache-generation/instance2-response.json new file mode 100644 index 0000000..c972017 --- /dev/null +++ b/src/test/resources/__files/cache-generation/instance2-response.json @@ -0,0 +1,10 @@ +{ + "instances": [ + { + "id": "5dbab2d4-42f6-47e0-b0c6-023040bd19ff ", + "title": "Test Title 2", + "source": "FOLIO" + } + ], + "totalRecords": 1 +} diff --git a/src/test/resources/mappings/cache-generation.json b/src/test/resources/mappings/cache-generation.json index 010714a..8bc97c5 100644 --- a/src/test/resources/mappings/cache-generation.json +++ b/src/test/resources/mappings/cache-generation.json @@ -13,7 +13,23 @@ "headers": { "Content-Type": "application/json" }, - "bodyFileName": "cache-generation/instance-response.json" + "bodyFileName": "cache-generation/instance1-response.json" + } + }, + { + "request": { + "method": "GET", + "urlPath": "/instance-storage/instances", + "queryParameters": { + "query": { "contains": "5dbab2d4-42f6-47e0-b0c6-023040bd19ff" } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "cache-generation/instance2-response.json" } }, { From c610d64c6dda1b6d87be430503a24d36e0e04937 Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Mon, 29 Dec 2025 16:21:49 +0200 Subject: [PATCH 4/6] fix: set isBoundWith to false when item belongs to holdings directly --- .../service/RtacCacheGenerationService.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java b/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java index af55784..65af295 100644 --- a/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java +++ b/src/main/java/org/folio/rtaccache/service/RtacCacheGenerationService.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.folio.rtaccache.client.InventoryClient; import org.folio.rtaccache.domain.RtacHoldingEntity; import org.folio.rtaccache.domain.RtacHoldingId; @@ -94,7 +95,7 @@ private CompletableFuture processDirectItemsForHolding(Instance instance, while (totalItems != 0 && itemsOffset < totalItems) { var itemsCql = getByHoldingsIdCql(holding.getId()); var itemsRequest = new FolioCqlRequest(itemsCql, ITEMS_BATCH_SIZE, itemsOffset); - futures.add(processItemsBatch(instance, holding, itemsRequest, false)); + futures.add(processItemsBatch(instance, holding, itemsRequest)); itemsOffset += ITEMS_BATCH_SIZE; } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); @@ -131,7 +132,7 @@ private CompletableFuture processBondWithItemBatch(Instance instance, Hold } var queryParamValue = buildIdOrCql(boundWithItemIds); var folioCqlRequest = new FolioCqlRequest(queryParamValue, boundWithItemIds.size(), 0); - return processItemsBatch(instance, holdings, folioCqlRequest, true); + return processItemsBatch(instance, holdings, folioCqlRequest); }, taskExecutor); } @@ -158,7 +159,7 @@ private CompletableFuture processPiecesForHolding(Instance instance, Holdi }, taskExecutor); } - private CompletableFuture processItemsBatch(Instance instance, HoldingsRecord holding, FolioCqlRequest request, boolean isBoundWith) { + private CompletableFuture processItemsBatch(Instance instance, HoldingsRecord holding, FolioCqlRequest request) { return CompletableFuture.supplyAsync(() -> { log.info("Sending request for items batch for holding id: {}, offset {}", holding.getId(), request.getOffset()); var itemsResponse = inventoryClient.getItems(request); @@ -168,7 +169,7 @@ private CompletableFuture processItemsBatch(Instance instance, HoldingsRec var itemsHoldCountMap = retrieveItemsHoldCountMap(items); var itemsLoanDueDateMap = retrieveItemsLoanDueDateMap(items); var rtacHoldings = items.stream() - .map(item -> processIndividualItem(instance, holding, item, itemsLoanDueDateMap, itemsHoldCountMap, isBoundWith)) + .map(item -> processIndividualItem(instance, holding, item, itemsLoanDueDateMap, itemsHoldCountMap)) .toList(); try { rtacHoldingBulkRepository.bulkUpsert(rtacHoldings); @@ -193,11 +194,12 @@ private boolean isInstanceShared(Instance instance) { return instance.getSource() != null && instance.getSource().contains(CONSORTIUM_SOURCE); } - private RtacHoldingEntity processIndividualItem(Instance instance, HoldingsRecord holding, Item item, Map dueDateMap, Map holdCountMap, boolean isBoundWith) { + private RtacHoldingEntity processIndividualItem(Instance instance, HoldingsRecord holding, Item item, + Map dueDateMap, Map holdCountMap) { var rtacHolding = rtacHoldingMappingService.mapFrom(holding, item); rtacHolding.setDueDate(dueDateMap.getOrDefault(rtacHolding.getId(), null)); rtacHolding.setTotalHoldRequests(Math.toIntExact(holdCountMap.getOrDefault(rtacHolding.getId(), 0L))); - rtacHolding.setIsBoundWith(isBoundWith); + rtacHolding.setIsBoundWith(isItemBoundWithHoldings(item, holding)); var entityId = RtacHoldingId.from(rtacHolding); return new RtacHoldingEntity(entityId, isInstanceShared(instance), rtacHolding, Instant.now()); } @@ -253,5 +255,8 @@ private String buildIdOrCql(List ids) { return ids.stream().map(id -> "id==" + id).collect(java.util.stream.Collectors.joining(" or ")); } + private boolean isItemBoundWithHoldings(Item item, HoldingsRecord holdings) { + return !StringUtils.equals(item.getHoldingsRecordId(), holdings.getId()); + } } From 8136989152c8aa926c5a8bdf922dfc0510bef4ca Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Mon, 29 Dec 2025 17:15:56 +0200 Subject: [PATCH 5/6] add logs --- .../handler/impl/ItemBoundWithDeleteEventHandler.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java index 64a61d9..acf4630 100644 --- a/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java +++ b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java @@ -2,6 +2,7 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; import org.folio.rtaccache.domain.RtacHoldingId; import org.folio.rtaccache.domain.dto.BoundWithPart; import org.folio.rtaccache.domain.dto.InventoryEntityType; @@ -16,6 +17,7 @@ @Service @RequiredArgsConstructor +@Log4j2 public class ItemBoundWithDeleteEventHandler implements InventoryEventHandler { private final RtacHoldingRepository holdingRepository; @@ -24,14 +26,18 @@ public class ItemBoundWithDeleteEventHandler implements InventoryEventHandler { @Override @Transactional public void handle(InventoryResourceEvent resourceEvent) { + log.info("Handling Item BoundWith delete event for resource: {}", resourceEvent); var boundWithPart = resourceEventUtil.getNewFromInventoryEvent(resourceEvent, BoundWithPart.class); var rtacHoldingId = new RtacHoldingId(); rtacHoldingId.setId(UUID.fromString(boundWithPart.getItemId())); rtacHoldingId.setType(TypeEnum.ITEM); rtacHoldingId.setInstanceId(UUID.fromString(boundWithPart.getInstanceId())); + log.info("Fetching RTAC holding for BoundWith item with id: {}", rtacHoldingId); holdingRepository.findById(rtacHoldingId).ifPresent(existingItemEntity -> { var itemRtacHolding = existingItemEntity.getRtacHolding(); + log.info("Found RTAC holding for BoundWith item: {}", itemRtacHolding); if (itemRtacHolding.getIsBoundWith()) { + log.info("Deleting RTAC holding for BoundWith item with id: {}", rtacHoldingId); holdingRepository.deleteById(rtacHoldingId); } }); From 2fd93d2d03caeb152b59e65794ff7dc4b3f22026 Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Mon, 29 Dec 2025 20:09:08 +0200 Subject: [PATCH 6/6] fix handong delete bound-with event --- .../service/handler/impl/ItemBoundWithDeleteEventHandler.java | 2 +- .../handler/impl/ItemBoundWithDeleteEventHandlerTest.java | 4 ++-- .../__files/kafka-events/delete-bound-with-event.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java index acf4630..df8cbb6 100644 --- a/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java +++ b/src/main/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandler.java @@ -27,7 +27,7 @@ public class ItemBoundWithDeleteEventHandler implements InventoryEventHandler { @Transactional public void handle(InventoryResourceEvent resourceEvent) { log.info("Handling Item BoundWith delete event for resource: {}", resourceEvent); - var boundWithPart = resourceEventUtil.getNewFromInventoryEvent(resourceEvent, BoundWithPart.class); + var boundWithPart = resourceEventUtil.getOldFromInventoryEvent(resourceEvent, BoundWithPart.class); var rtacHoldingId = new RtacHoldingId(); rtacHoldingId.setId(UUID.fromString(boundWithPart.getItemId())); rtacHoldingId.setType(TypeEnum.ITEM); diff --git a/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java index 90d173b..1079702 100644 --- a/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java +++ b/src/test/java/org/folio/rtaccache/service/handler/impl/ItemBoundWithDeleteEventHandlerTest.java @@ -67,8 +67,8 @@ void itemBoundWithDelete_shouldNotDelete_whenItemIsNotBoundWith() { } private InventoryResourceEvent setUpEventWith(BoundWithPart part) { - var event = new InventoryResourceEvent().type(InventoryEventType.DELETE)._new(part); - when(resourceEventUtil.getNewFromInventoryEvent(event, BoundWithPart.class)).thenReturn(part); + var event = new InventoryResourceEvent().type(InventoryEventType.DELETE).old(part); + when(resourceEventUtil.getOldFromInventoryEvent(event, BoundWithPart.class)).thenReturn(part); return event; } diff --git a/src/test/resources/__files/kafka-events/delete-bound-with-event.json b/src/test/resources/__files/kafka-events/delete-bound-with-event.json index c1d9c15..3d93fa5 100644 --- a/src/test/resources/__files/kafka-events/delete-bound-with-event.json +++ b/src/test/resources/__files/kafka-events/delete-bound-with-event.json @@ -1,5 +1,5 @@ { - "new": { + "old": { "instanceId": "843b368d-411c-4dce-bd64-99afc53f508d", "id": "6fc15a10-aab1-4726-9d5a-ee51cdf80606", "holdingsRecordId": "55fa3746-8176-49c5-9809-b29dd7bb9b47",