diff --git a/ramls/acq-models b/ramls/acq-models index 82e7e5606..96d86e360 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit 82e7e5606af023935220552ef00391b13f192229 +Subproject commit 96d86e360bf18a6f384ffed46b79ec949a6f1caa diff --git a/src/main/java/org/folio/config/ApplicationConfig.java b/src/main/java/org/folio/config/ApplicationConfig.java index 03574a966..19ef541f1 100644 --- a/src/main/java/org/folio/config/ApplicationConfig.java +++ b/src/main/java/org/folio/config/ApplicationConfig.java @@ -425,10 +425,14 @@ HoldingsSummaryService holdingsSummaryService(PurchaseOrderStorageService purcha } @Bean - HoldingDetailService holdingsDetailService(PurchaseOrderLineService purchaseOrderLineService, + HoldingDetailService holdingsDetailService(ConsortiumConfigurationService consortiumConfigurationService, + ConsortiumUserTenantsRetriever consortiumUserTenantsRetriever, + SettingsRetriever settingsRetriever, + PurchaseOrderLineService purchaseOrderLineService, PieceStorageService pieceStorageService, InventoryItemManager inventoryItemManager) { - return new HoldingDetailService(purchaseOrderLineService, pieceStorageService, inventoryItemManager); + return new HoldingDetailService(consortiumConfigurationService, consortiumUserTenantsRetriever, settingsRetriever, + purchaseOrderLineService, pieceStorageService, inventoryItemManager); } @Bean diff --git a/src/main/java/org/folio/models/HoldingDetailAggregator.java b/src/main/java/org/folio/models/HoldingDetailAggregator.java new file mode 100644 index 000000000..32f8430e8 --- /dev/null +++ b/src/main/java/org/folio/models/HoldingDetailAggregator.java @@ -0,0 +1,65 @@ +package org.folio.models; + +import io.vertx.core.json.JsonObject; +import org.apache.commons.lang3.StringUtils; +import org.folio.rest.jaxrs.model.Piece; +import org.folio.rest.jaxrs.model.PoLine; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class HoldingDetailAggregator { + + private String tenant; + private Map> poLinesByHoldingId = new HashMap<>(); + private Map> piecesByHoldingId = new HashMap<>(); + private Map> itemsByHoldingId = new HashMap<>(); + + public String getTenant() { + return tenant; + } + + public Map> getPoLinesByHoldingId() { + return poLinesByHoldingId; + } + + public Map> getPiecesByHoldingId() { + return piecesByHoldingId; + } + + public Map> getItemsByHoldingId() { + return itemsByHoldingId; + } + + public String getPieceTenantIdByItemId(String itemId) { + if (Objects.isNull(itemId)) { + return null; + } + return piecesByHoldingId.values().stream() + .flatMap(List::stream) + .filter(Objects::nonNull) + .filter(piece -> StringUtils.equals(itemId, piece.getItemId())) + .map(Piece::getReceivingTenantId) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + public void setTenant(String tenant) { + this.tenant = tenant; + } + + public void setPoLinesByHoldingId(Map> poLines) { + this.poLinesByHoldingId = Objects.requireNonNullElse(poLines, new HashMap<>()); + } + + public void setPiecesByHoldingId(Map> pieces) { + this.piecesByHoldingId = Objects.requireNonNullElse(pieces, new HashMap<>()); + } + + public void setItemsByHoldingId(Map> items) { + this.itemsByHoldingId = Objects.requireNonNullElse(items, new HashMap<>()); + } +} diff --git a/src/main/java/org/folio/models/HoldingDetailHolder.java b/src/main/java/org/folio/models/HoldingDetailHolder.java deleted file mode 100644 index c9bae4cad..000000000 --- a/src/main/java/org/folio/models/HoldingDetailHolder.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.folio.models; - -import org.folio.rest.jaxrs.model.ItemsDetail; -import org.folio.rest.jaxrs.model.PiecesDetail; -import org.folio.rest.jaxrs.model.PoLinesDetail; - -import java.util.List; - -public record HoldingDetailHolder(String holdingId, List poLines, List pieces, List items) { -} diff --git a/src/main/java/org/folio/service/inventory/InventoryItemManager.java b/src/main/java/org/folio/service/inventory/InventoryItemManager.java index 718b2e17e..b7aec341f 100644 --- a/src/main/java/org/folio/service/inventory/InventoryItemManager.java +++ b/src/main/java/org/folio/service/inventory/InventoryItemManager.java @@ -30,12 +30,14 @@ import org.folio.service.pieces.PieceUtil; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import static java.util.stream.Collectors.toList; +import static one.util.streamex.StreamEx.ofSubLists; import static org.folio.orders.utils.HelperUtils.collectResultsOnSuccess; import static org.folio.orders.utils.QueryUtils.convertIdsToCqlQuery; import static org.folio.rest.RestConstants.MAX_IDS_FOR_GET_RQ_15; @@ -266,8 +268,8 @@ private List getItemsByMaterialType(List existingItems, Stri .collect(toList()); } - private List buildPieces(Location location, PoLine poLine, Piece.Format pieceFormat, List createdItemIds, - List existingItemIds) { + private List buildPieces(Location location, PoLine poLine, Piece.Format pieceFormat, + List createdItemIds, List existingItemIds) { List itemIds = ListUtils.union(createdItemIds, existingItemIds); logger.info(BUILDING_PIECE_MESSAGE, itemIds.size(), pieceFormat, poLine.getId()); return StreamEx.of(itemIds).map(itemId -> openOrderBuildPiece(poLine, itemId, pieceFormat, location)).toList(); @@ -309,6 +311,17 @@ public Future> getItemsByHoldingId(String holdingId, RequestCon }); } + public Future> getItemsByHoldingIds(List holdingIds, RequestContext requestContext) { + var futures = ofSubLists(new ArrayList<>(holdingIds), MAX_IDS_FOR_GET_RQ_15) + .map(holdingIdsChunk -> convertIdsToCqlQuery(holdingIdsChunk, ITEM_HOLDINGS_RECORD_ID)) + .map(query -> getItemRecordsByQuery(query, requestContext)) + .toList(); + return collectResultsOnSuccess(futures) + .map(lists -> StreamEx.of(lists) + .flatMap(Collection::stream) + .toList()); + } + public Future openOrderCreateItemRecord(CompositePurchaseOrder compPO, PoLine poLine, String holdingId, RequestContext requestContext) { final int ITEM_QUANTITY = 1; diff --git a/src/main/java/org/folio/service/orders/HoldingDetailService.java b/src/main/java/org/folio/service/orders/HoldingDetailService.java index 2c444fb8b..44a3409fb 100644 --- a/src/main/java/org/folio/service/orders/HoldingDetailService.java +++ b/src/main/java/org/folio/service/orders/HoldingDetailService.java @@ -4,9 +4,8 @@ import io.vertx.core.json.JsonObject; import lombok.extern.log4j.Log4j2; import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang.StringUtils; -import org.folio.models.HoldingDetailHolder; +import org.folio.models.HoldingDetailAggregator; +import org.folio.rest.acq.model.Setting; import org.folio.rest.core.models.RequestContext; import org.folio.rest.jaxrs.model.HoldingDetailResults; import org.folio.rest.jaxrs.model.HoldingDetailResultsProperty; @@ -18,11 +17,17 @@ import org.folio.rest.jaxrs.model.PoLine; import org.folio.rest.jaxrs.model.PoLinesDetail; import org.folio.rest.jaxrs.model.PoLinesDetailCollection; +import org.folio.rest.tools.utils.TenantTool; +import org.folio.service.consortium.ConsortiumConfigurationService; +import org.folio.service.consortium.ConsortiumUserTenantsRetriever; import org.folio.service.inventory.InventoryItemManager; import org.folio.service.pieces.PieceStorageService; +import org.folio.service.settings.SettingsRetriever; +import org.folio.service.settings.util.SettingKey; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -31,17 +36,29 @@ import static org.folio.orders.utils.HelperUtils.collectResultsOnSuccess; import static org.folio.orders.utils.RequestContextUtil.createContextWithNewTenantId; import static org.folio.service.inventory.InventoryItemManager.ID; +import static org.folio.service.inventory.InventoryItemManager.ITEM_HOLDINGS_RECORD_ID; +// TODO CI retrigger @Log4j2 public class HoldingDetailService { + private static final String TRUE = "true"; + private static final String FALSE = "false"; + private final ConsortiumConfigurationService consortiumConfigurationService; + private final ConsortiumUserTenantsRetriever consortiumUserTenantsRetriever; + private final SettingsRetriever settingsRetriever; private final PurchaseOrderLineService purchaseOrderLineService; private final PieceStorageService pieceStorageService; private final InventoryItemManager inventoryItemManager; - public HoldingDetailService(PurchaseOrderLineService purchaseOrderLineService, + public HoldingDetailService(ConsortiumConfigurationService consortiumConfigurationService, + ConsortiumUserTenantsRetriever consortiumUserTenantsRetriever, SettingsRetriever settingsRetriever, + PurchaseOrderLineService purchaseOrderLineService, PieceStorageService pieceStorageService, InventoryItemManager inventoryItemManager) { + this.consortiumConfigurationService = consortiumConfigurationService; + this.consortiumUserTenantsRetriever = consortiumUserTenantsRetriever; + this.settingsRetriever = settingsRetriever; this.purchaseOrderLineService = purchaseOrderLineService; this.pieceStorageService = pieceStorageService; this.inventoryItemManager = inventoryItemManager; @@ -52,187 +69,194 @@ public Future postOrdersHoldingDetail(List holding log.info("postOrdersHoldingDetail:: No holding ids were passed"); return Future.succeededFuture(new HoldingDetailResults()); } - return purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext) - .compose(poLines -> { - if (CollectionUtils.isEmpty(poLines)) { - log.info("postOrdersHoldingDetail:: No poLines were found by holding ids={}", holdingIds); - return Future.succeededFuture(new HoldingDetailResults()); + return getUserTenantsIfNeeded(requestContext) + .compose(userTenants -> { + var aggregatorFutures = new ArrayList>(); + if (CollectionUtils.isNotEmpty(userTenants)) { + userTenants.forEach(tenantId -> { + var localRequestContext = createContextWithNewTenantId(requestContext, tenantId); + aggregatorFutures.add(aggregateHoldingDetailByTenant(holdingIds, localRequestContext)); + }); + } else { + aggregatorFutures.add(aggregateHoldingDetailByTenant(holdingIds, requestContext)); } - var poLineIds = poLines.stream().map(PoLine::getId).distinct().toList(); - return pieceStorageService.getPiecesByLineIdsByChunks(poLineIds, requestContext) - .compose(pieces -> { - if (CollectionUtils.isEmpty(pieces)) { - log.info("postOrdersHoldingDetail:: No pieces were found by holding ids={}, poLine ids={}", holdingIds, poLineIds); - var groupedPoLinesDetailsByHoldingId = createGroupedPoLineDetailsByHoldingId(holdingIds, poLines); - return Future.succeededFuture(createHoldingDetailsResultsWithPoLines(groupedPoLinesDetailsByHoldingId)); + return collectResultsOnSuccess(aggregatorFutures); + }) + .compose(aggregators -> { + var holdingDetailResults = new HoldingDetailResults(); + + // Traverse aggregates and find if any contain necessary values by a holding id + holdingIds.forEach(holdingId -> { + // Accumulate data from all aggregators for this holdingId + var allPoLinesDetails = new ArrayList(); + var allPiecesDetails = new ArrayList(); + var allItemsDetails = new ArrayList(); + + aggregators.forEach(aggregator -> { + if (aggregator.getPoLinesByHoldingId().containsKey(holdingId)) { + var poLinesDetails = aggregator.getPoLinesByHoldingId().get(holdingId).stream() + .filter(Objects::nonNull) + .map(poLine -> new PoLinesDetail().withId(poLine.getId()) + .withCheckinItems(poLine.getCheckinItems())) + .toList(); + allPoLinesDetails.addAll(poLinesDetails); + } + if (aggregator.getPiecesByHoldingId().containsKey(holdingId)) { + var piecesDetails = aggregator.getPiecesByHoldingId().get(holdingId).stream() + .filter(Objects::nonNull) + .map(piece -> new PiecesDetail().withId(piece.getId()) + .withPoLineId(piece.getPoLineId()) + .withItemId(piece.getItemId()) + .withTenantId(piece.getReceivingTenantId())) + .toList(); + allPiecesDetails.addAll(piecesDetails); } - var holdersFutures = new ArrayList>(); - groupPiecesByTenantIdAndHoldingId(pieces) - .forEach((tenantId, groupedPiecesByHoldingId) -> { - if (MapUtils.isNotEmpty(groupedPiecesByHoldingId)) { - groupedPiecesByHoldingId.forEach((holdingId, groupedPieces) -> { - log.info("postOrdersHoldingDetail:: Processing holding detail by tenant={}, holding id={}, pieces={}", getTenantId(tenantId), holdingId, groupedPieces.size()); - var poLinesDetails = createPoLineDetailFromPieces(groupedPieces); - var piecesDetail = createPieceDetail(groupedPieces); - var holdersFuture = getHolderFuture(tenantId, holdingId, poLinesDetails, piecesDetail, requestContext); - holdersFutures.add(holdersFuture); - }); - } - }); - return collectResultsOnSuccess(holdersFutures) - .map(this::createHoldingDetailResults); + if (aggregator.getItemsByHoldingId().containsKey(holdingId)) { + var itemsDetails = aggregator.getItemsByHoldingId().get(holdingId).stream() + .filter(Objects::nonNull) + .map(item -> new ItemsDetail().withId(item.getString(ID)) + .withTenantId(aggregator.getPieceTenantIdByItemId(item.getString(ID)))) + .toList(); + allItemsDetails.addAll(itemsDetails); + } + }); + + // Build collections with accumulated data + var poLinesDetailCollection = new PoLinesDetailCollection() + .withPoLinesDetail(allPoLinesDetails) + .withTotalRecords(allPoLinesDetails.size()); + var piecesDetailCollection = new PiecesDetailCollection() + .withPiecesDetail(allPiecesDetails) + .withTotalRecords(allPiecesDetails.size()); + var itemsDetailCollection = new ItemsDetailCollection() + .withItemsDetail(allItemsDetails) + .withTotalRecords(allItemsDetails.size()); + + log.info("postOrdersHoldingDetail:: Prepared accumulated data for {} holding with poLines={}, pieces={}, items={} for tenants={}", holdingId, + allPoLinesDetails.size(), allPiecesDetails.size(), allItemsDetails.size(), + aggregators.stream().map(HoldingDetailAggregator::getTenant).filter(Objects::nonNull).toList()); + + var holdingDetailProperty = new HoldingDetailResultsProperty() + .withPoLinesDetailCollection(poLinesDetailCollection) + .withPiecesDetailCollection(piecesDetailCollection) + .withItemsDetailCollection(itemsDetailCollection); + holdingDetailResults.withAdditionalProperty(holdingId, holdingDetailProperty); }); + + return Future.succeededFuture(holdingDetailResults); }) .recover(throwable -> { - log.error("postOrdersHoldingDetail:: Error processing holding details for holding ids={}", holdingIds, throwable); + log.error("postOrdersHoldingDetail:: Error building holding detail results for holding ids={}", holdingIds, throwable); return Future.failedFuture(throwable); }); } - protected Map>> groupPiecesByTenantIdAndHoldingId(List pieces) { - if (CollectionUtils.isEmpty(pieces)) { - return Collections.emptyMap(); - } - return pieces.stream() - .filter(Objects::nonNull) - .filter(piece -> Objects.nonNull(piece.getHoldingId())) - .map(this::getPieceWithNonNullReceivingTenant) - .collect(Collectors.groupingBy(Piece::getReceivingTenantId, Collectors.groupingBy(Piece::getHoldingId))); - } + private Future aggregateHoldingDetailByTenant(List holdingIds, RequestContext requestContext) { + var aggregator = new HoldingDetailAggregator(); + aggregator.setTenant(TenantTool.tenantId(requestContext.getHeaders())); - private Piece getPieceWithNonNullReceivingTenant(Piece piece) { - return Objects.nonNull(piece.getReceivingTenantId()) ? piece : piece.withReceivingTenantId(""); - } + log.info("aggregateHoldingDetailByTenant:: Aggregating poLines, pieces and items for tenant={}", aggregator.getTenant()); + + // Aggregate poLines, pieces, and items independently in parallel + var poLinesFuture = purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext) + .map(poLines -> { + log.info("aggregateHoldingDetailByTenant:: Found {} poLines by holding ids={}", poLines.size(), holdingIds); + var poLinesByHoldingIds = groupedPoLinesByHoldingId(holdingIds, poLines); + aggregator.setPoLinesByHoldingId(poLinesByHoldingIds); + return null; + }) + .recover(throwable -> { + log.warn("aggregateHoldingDetailByTenant:: Failed to retrieve poLines for holding ids={}", holdingIds, throwable); + aggregator.setPoLinesByHoldingId(Collections.emptyMap()); + return Future.succeededFuture(null); + }); + + var piecesFuture = pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext) + .map(pieces -> { + log.info("aggregateHoldingDetailByTenant:: Found {} pieces by holding ids={}", pieces.size(), holdingIds); + var piecesByHoldingId = groupPiecesByHoldingId(pieces); + aggregator.setPiecesByHoldingId(piecesByHoldingId); + return null; + }) + .recover(throwable -> { + log.warn("aggregateHoldingDetailByTenant:: Failed to retrieve pieces for holding ids={}", holdingIds, throwable); + aggregator.setPiecesByHoldingId(Collections.emptyMap()); + return Future.succeededFuture(null); + }); - protected Future getHolderFuture(String tenantId, String holdingId, List poLinesDetails, - List piecesDetail, RequestContext requestContext) { - var localRequestContext = StringUtils.isNotBlank(tenantId) ? createContextWithNewTenantId(requestContext, tenantId) : requestContext; - return inventoryItemManager.getItemsByHoldingId(holdingId, localRequestContext) + var itemsFuture = inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext) .map(items -> { - if (CollectionUtils.isEmpty(items)) { - return Collections.emptyList(); + log.info("aggregateHoldingDetailByTenant:: Found {} items by holding ids={}", items.size(), holdingIds); + var itemsByHoldingId = groupItemsByHoldingId(items); + aggregator.setItemsByHoldingId(itemsByHoldingId); + return null; + }) + .recover(throwable -> { + log.warn("aggregateHoldingDetailByTenant:: Failed to retrieve items for holding ids={}", holdingIds, throwable); + aggregator.setItemsByHoldingId(Collections.emptyMap()); + return Future.succeededFuture(null); + }); + + return Future.all(poLinesFuture, piecesFuture, itemsFuture) + .map(v -> aggregator); + } + + protected Future> getUserTenantsIfNeeded(RequestContext requestContext) { + return consortiumConfigurationService.getConsortiumConfiguration(requestContext) + .compose(consortiumConfiguration -> { + if (consortiumConfiguration.isEmpty()) { + return Future.succeededFuture(Collections.emptyList()); } - return items.stream() - .filter(Objects::nonNull) - .map(item -> createItemDetail(tenantId, item)) - .toList(); + var configuration = consortiumConfiguration.get(); + return settingsRetriever.getSettingByKey(SettingKey.CENTRAL_ORDERING_ENABLED, requestContext) + .compose(centralOrdering -> { + var isEnabled = centralOrdering.map(Setting::getValue).orElse(FALSE); + if (!TRUE.equalsIgnoreCase(isEnabled)) { + log.info("getUserTenantsIfNeeded:: Central ordering is disabled or not configured"); + return Future.succeededFuture(Collections.emptyList()); + } + return consortiumUserTenantsRetriever.getUserTenants(configuration.consortiumId(), configuration.centralTenantId(), requestContext); + }); }) - .map(itemsDetail -> { - var finalPoLinesDetail = CollectionUtils.isNotEmpty(poLinesDetails) ? poLinesDetails : Collections.emptyList(); - var finalPiecesDetail = CollectionUtils.isNotEmpty(piecesDetail) ? piecesDetail : Collections.emptyList(); - return new HoldingDetailHolder(holdingId, finalPoLinesDetail, finalPiecesDetail, itemsDetail); + .recover(throwable -> { + log.warn("getUserTenantsIfNeeded:: Failed to retrieve user tenants, falling back to current tenant only", throwable); + return Future.succeededFuture(Collections.emptyList()); }); } - private Map> createGroupedPoLineDetailsByHoldingId(List holdingIds, List poLines) { + private Map> groupedPoLinesByHoldingId(List holdingIds, List poLines) { if (CollectionUtils.isEmpty(poLines)) { return Collections.emptyMap(); } - var holdingIdSet = CollectionUtils.isEmpty(holdingIds) ? Collections.emptySet() : new java.util.HashSet<>(holdingIds); + var holdingIdSet = new HashSet<>(holdingIds); return poLines.stream() .filter(Objects::nonNull) .filter(poLine -> CollectionUtils.isNotEmpty(poLine.getLocations())) .flatMap(poLine -> poLine.getLocations().stream() .filter(location -> Objects.nonNull(location.getHoldingId())) .filter(location -> holdingIdSet.contains(location.getHoldingId())) - .map(location -> Map.entry(location.getHoldingId(), poLine.getId()))) + .map(location -> Map.entry(location.getHoldingId(), poLine))) .distinct() - .collect(Collectors.groupingBy( - Map.Entry::getKey, - Collectors.mapping( - entry -> new PoLinesDetail().withId(entry.getValue()), - Collectors.toList() - ) - )); + .collect(Collectors.groupingBy(Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); } - private List createPoLineDetailFromPieces(List groupedPieces) { - if (CollectionUtils.isEmpty(groupedPieces)) { - return Collections.emptyList(); + private Map> groupPiecesByHoldingId(List pieces) { + if (CollectionUtils.isEmpty(pieces)) { + return Collections.emptyMap(); } - return groupedPieces.stream() + return pieces.stream() .filter(Objects::nonNull) - .map(Piece::getPoLineId) - .distinct() - .map(poLineId -> new PoLinesDetail().withId(poLineId)) - .toList(); + .filter(piece -> Objects.nonNull(piece.getHoldingId())) + .collect(Collectors.groupingBy(Piece::getHoldingId)); } - private List createPieceDetail(List groupedPieces) { - if (CollectionUtils.isEmpty(groupedPieces)) { - return Collections.emptyList(); + private Map> groupItemsByHoldingId(List items) { + if (CollectionUtils.isEmpty(items)) { + return Collections.emptyMap(); } - return groupedPieces.stream() + return items.stream() .filter(Objects::nonNull) - .map(piece -> new PiecesDetail() - .withId(piece.getId()) - .withPoLineId(piece.getPoLineId()) - .withItemId(piece.getItemId()) - .withTenantId(getTenantId(piece.getReceivingTenantId()))) - .toList(); - } - - private ItemsDetail createItemDetail(String tenantId, JsonObject item) { - return new ItemsDetail() - .withId(item.getString(ID)) - .withTenantId(getTenantId(tenantId)); - } - - private String getTenantId(String tenantId) { - return StringUtils.isNotBlank(tenantId) ? tenantId : null; - } - - private HoldingDetailResults createHoldingDetailsResultsWithPoLines(Map> groupedPoLinesDetailsByHoldingId) { - if (MapUtils.isEmpty(groupedPoLinesDetailsByHoldingId)) { - log.info("createHoldingDetailsResultsWithPoLines:: No detail holders were generated"); - return new HoldingDetailResults(); - } - var holdingDetailResults = new HoldingDetailResults(); - groupedPoLinesDetailsByHoldingId.forEach((holdingId, poLinesDetails) -> { - var poLinesDetailCollection = new PoLinesDetailCollection(); - var piecesDetailCollection = new PiecesDetailCollection(); - var itemsDetailCollection = new ItemsDetailCollection(); - if (CollectionUtils.isNotEmpty(poLinesDetails)) { - poLinesDetailCollection.withPoLinesDetail(poLinesDetails) - .withTotalRecords(poLinesDetails.size()); - } - var holdingDetailProperty = new HoldingDetailResultsProperty() - .withPoLinesDetailCollection(poLinesDetailCollection) - .withPiecesDetailCollection(piecesDetailCollection) - .withItemsDetailCollection(itemsDetailCollection); - holdingDetailResults.withAdditionalProperty(holdingId, holdingDetailProperty); - }); - return holdingDetailResults; - } - - private HoldingDetailResults createHoldingDetailResults(List holders) { - if (CollectionUtils.isEmpty(holders)) { - log.info("createHoldingDetailResults:: No detail holders were generated"); - return new HoldingDetailResults(); - } - var holdingDetailResults = new HoldingDetailResults(); - holders.forEach(holder -> { - var poLinesDetailCollection = new PoLinesDetailCollection(); - var piecesDetailCollection = new PiecesDetailCollection(); - var itemsDetailCollection = new ItemsDetailCollection(); - if (CollectionUtils.isNotEmpty(holder.poLines())) { - poLinesDetailCollection.withPoLinesDetail(holder.poLines()) - .withTotalRecords(holder.poLines().size()); - } - if (CollectionUtils.isNotEmpty(holder.pieces())) { - piecesDetailCollection.withPiecesDetail(holder.pieces()) - .withTotalRecords(holder.pieces().size()); - } - if (CollectionUtils.isNotEmpty(holder.items())) { - itemsDetailCollection.withItemsDetail(holder.items()) - .withTotalRecords(holder.items().size()); - } - var holdingDetailProperty = new HoldingDetailResultsProperty() - .withPoLinesDetailCollection(poLinesDetailCollection) - .withPiecesDetailCollection(piecesDetailCollection) - .withItemsDetailCollection(itemsDetailCollection); - holdingDetailResults.withAdditionalProperty(holder.holdingId(), holdingDetailProperty); - }); - return holdingDetailResults; + .filter(item -> Objects.nonNull(item.getString(ITEM_HOLDINGS_RECORD_ID))) + .collect(Collectors.groupingBy(item -> item.getString(ITEM_HOLDINGS_RECORD_ID))); } } diff --git a/src/test/java/org/folio/service/orders/HoldingDetailServiceTest.java b/src/test/java/org/folio/service/orders/HoldingDetailServiceTest.java index 02fa660f0..7f99a7f69 100644 --- a/src/test/java/org/folio/service/orders/HoldingDetailServiceTest.java +++ b/src/test/java/org/folio/service/orders/HoldingDetailServiceTest.java @@ -11,10 +11,15 @@ import org.folio.rest.core.models.RequestContext; import org.folio.rest.jaxrs.model.Location; import org.folio.rest.jaxrs.model.Piece; -import org.folio.rest.jaxrs.model.PiecesDetail; import org.folio.rest.jaxrs.model.PoLine; +import org.folio.rest.tools.utils.TenantTool; +import org.folio.models.consortium.ConsortiumConfiguration; +import org.folio.rest.acq.model.Setting; +import org.folio.service.consortium.ConsortiumConfigurationService; +import org.folio.service.consortium.ConsortiumUserTenantsRetriever; import org.folio.service.inventory.InventoryItemManager; import org.folio.service.pieces.PieceStorageService; +import org.folio.service.settings.SettingsRetriever; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,6 +27,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; + import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -29,7 +35,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.UUID; import java.util.stream.Stream; @@ -37,8 +42,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,6 +53,9 @@ public class HoldingDetailServiceTest { @InjectMocks private HoldingDetailService holdingDetailService; + @Mock private ConsortiumConfigurationService consortiumConfigurationService; + @Mock private ConsortiumUserTenantsRetriever consortiumUserTenantsRetriever; + @Mock private SettingsRetriever settingsRetriever; @Mock private PurchaseOrderLineService purchaseOrderLineService; @Mock private PieceStorageService pieceStorageService; @Mock private InventoryItemManager inventoryItemManager; @@ -63,6 +71,11 @@ void setUp() { okapiHeaders.put(XOkapiHeaders.USER_ID, "test-user"); when(requestContext.getHeaders()).thenReturn(okapiHeaders); when(requestContext.getContext()).thenReturn(vertxContext); + + // Setup default behavior for consortium and settings mocks to return empty results + // This ensures existing tests continue to work without modification (non-consortium mode) + when(consortiumConfigurationService.getConsortiumConfiguration(any())) + .thenReturn(Future.succeededFuture(java.util.Optional.empty())); } @AfterEach @@ -72,9 +85,10 @@ void tearDown() throws Exception { } } - @Test - void testPostOrdersHoldingDetailWithNullHoldingIds(VertxTestContext vertxTestContext) { - var future = holdingDetailService.postOrdersHoldingDetail(null, requestContext); + @ParameterizedTest(name = "{0}") + @MethodSource("provideNullOrEmptyHoldingIds") + void testPostOrdersHoldingDetailWithInvalidHoldingIds(String testName, List holdingIds, VertxTestContext vertxTestContext) { + var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); vertxTestContext.assertComplete(future) .onComplete(result -> { @@ -83,25 +97,17 @@ void testPostOrdersHoldingDetailWithNullHoldingIds(VertxTestContext vertxTestCon assertNotNull(holdingDetailResults); assertEquals(0, holdingDetailResults.getAdditionalProperties().size()); verify(purchaseOrderLineService, never()).getPoLinesByHoldingIds(any(), any()); - verify(pieceStorageService, never()).getPiecesByLineIdsByChunks(any(), any()); + verify(pieceStorageService, never()).getPiecesByHoldingIds(any(), any()); + verify(inventoryItemManager, never()).getItemsByHoldingIds(any(), any()); vertxTestContext.completeNow(); }); } - @Test - void testPostOrdersHoldingDetailWithEmptyHoldingIds(VertxTestContext vertxTestContext) { - var future = holdingDetailService.postOrdersHoldingDetail(Collections.emptyList(), requestContext); - - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - var holdingDetailResults = result.result(); - assertNotNull(holdingDetailResults); - assertEquals(0, holdingDetailResults.getAdditionalProperties().size()); - verify(purchaseOrderLineService, never()).getPoLinesByHoldingIds(any(), any()); - verify(pieceStorageService, never()).getPiecesByLineIdsByChunks(any(), any()); - vertxTestContext.completeNow(); - }); + static Stream provideNullOrEmptyHoldingIds() { + return Stream.of( + Arguments.of("Null holding IDs", null), + Arguments.of("Empty holding IDs list", Collections.emptyList()) + ); } @Test @@ -113,7 +119,9 @@ void testPostOrdersHoldingDetailWithNoPieces(VertxTestContext vertxTestContext) when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -128,9 +136,10 @@ void testPostOrdersHoldingDetailWithNoPieces(VertxTestContext vertxTestContext) var property = holdingDetailResults.getAdditionalProperties().get(holdingId); assertEquals(1, property.getPoLinesDetailCollection().getPoLinesDetail().size()); assertEquals(0, property.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(0, property.getItemsDetailCollection().getItemsDetail().size()); verify(purchaseOrderLineService).getPoLinesByHoldingIds(holdingIds, requestContext); - verify(pieceStorageService).getPiecesByLineIdsByChunks(List.of(poLineId), requestContext); - verify(inventoryItemManager, never()).getItemsByHoldingId(anyString(), any()); + verify(pieceStorageService).getPiecesByHoldingIds(holdingIds, requestContext); + verify(inventoryItemManager).getItemsByHoldingIds(holdingIds, requestContext); vertxTestContext.completeNow(); }); } @@ -158,9 +167,9 @@ void testPostOrdersHoldingDetailSuccess(VertxTestContext vertxTestContext) { when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(item))); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -184,8 +193,8 @@ void testPostOrdersHoldingDetailSuccess(VertxTestContext vertxTestContext) { assertEquals(1, property.getItemsDetailCollection().getItemsDetail().size()); verify(purchaseOrderLineService).getPoLinesByHoldingIds(holdingIds, requestContext); - verify(pieceStorageService).getPiecesByLineIdsByChunks(List.of(poLineId), requestContext); - verify(inventoryItemManager).getItemsByHoldingId(eq(holdingId), any(RequestContext.class)); + verify(pieceStorageService).getPiecesByHoldingIds(holdingIds, requestContext); + verify(inventoryItemManager).getItemsByHoldingIds(holdingIds, requestContext); vertxTestContext.completeNow(); }); } @@ -217,17 +226,15 @@ void testPostOrdersHoldingDetailMultipleHoldingsAndTenants(VertxTestContext vert .withItemId(UUID.randomUUID().toString()) .withReceivingTenantId(tenant2); - var item1 = new JsonObject().put("id", UUID.randomUUID().toString()); - var item2 = new JsonObject().put("id", UUID.randomUUID().toString()); + var item1 = new JsonObject().put("id", UUID.randomUUID().toString()).put("holdingsRecordId", holdingId1); + var item2 = new JsonObject().put("id", UUID.randomUUID().toString()).put("holdingsRecordId", holdingId2); when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine1, poLine2))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId1, poLineId2), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece1, piece2))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId1), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(List.of(item1))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId2), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(List.of(item2))); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(List.of(item1, item2))); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -241,9 +248,8 @@ void testPostOrdersHoldingDetailMultipleHoldingsAndTenants(VertxTestContext vert assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId2)); verify(purchaseOrderLineService).getPoLinesByHoldingIds(holdingIds, requestContext); - verify(pieceStorageService).getPiecesByLineIdsByChunks(List.of(poLineId1, poLineId2), requestContext); - verify(inventoryItemManager).getItemsByHoldingId(eq(holdingId1), any(RequestContext.class)); - verify(inventoryItemManager).getItemsByHoldingId(eq(holdingId2), any(RequestContext.class)); + verify(pieceStorageService).getPiecesByHoldingIds(holdingIds, requestContext); + verify(inventoryItemManager).getItemsByHoldingIds(holdingIds, requestContext); vertxTestContext.completeNow(); }); } @@ -262,13 +268,13 @@ void testPostOrdersHoldingDetailWithNullTenantId(VertxTestContext vertxTestConte .withItemId(UUID.randomUUID().toString()) .withReceivingTenantId(null); - var item = new JsonObject().put("id", UUID.randomUUID().toString()); + var item = new JsonObject().put("id", UUID.randomUUID().toString()).put("holdingsRecordId", holdingId); when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(item))); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -300,9 +306,9 @@ void testPostOrdersHoldingDetailWithEmptyItems(VertxTestContext vertxTestContext when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -323,308 +329,55 @@ void testPostOrdersHoldingDetailWithEmptyItems(VertxTestContext vertxTestContext } @ParameterizedTest(name = "{0}") - @MethodSource("provideGroupingTestCases") - void testGroupPiecesByTenantIdAndHoldingId(String testName, List pieces, int expectedTenantGroups, - int expectedHoldingGroups) { - var result = holdingDetailService.groupPiecesByTenantIdAndHoldingId(pieces); - - assertNotNull(result); - assertEquals(expectedTenantGroups, result.size()); - - var totalHoldingGroups = result.values().stream() - .mapToInt(Map::size) - .sum(); - assertEquals(expectedHoldingGroups, totalHoldingGroups); - } - - static Stream provideGroupingTestCases() { - var holdingId1 = UUID.randomUUID().toString(); - var holdingId2 = UUID.randomUUID().toString(); - var tenant1 = "tenant-1"; - var tenant2 = "tenant-2"; - - return Stream.of( - Arguments.of( - "Empty list", - Collections.emptyList(), - 0, 0 - ), - Arguments.of( - "Single piece with tenant and holding", - List.of(createPiece(holdingId1, tenant1)), - 1, 1 - ), - Arguments.of( - "Multiple pieces same tenant and holding", - List.of( - createPiece(holdingId1, tenant1), - createPiece(holdingId1, tenant1) - ), - 1, 1 - ), - Arguments.of( - "Multiple pieces same tenant different holdings", - List.of( - createPiece(holdingId1, tenant1), - createPiece(holdingId2, tenant1) - ), - 1, 2 - ), - Arguments.of( - "Multiple pieces different tenants same holding", - List.of( - createPiece(holdingId1, tenant1), - createPiece(holdingId1, tenant2) - ), - 2, 2 - ), - Arguments.of( - "Multiple pieces different tenants different holdings", - List.of( - createPiece(holdingId1, tenant1), - createPiece(holdingId2, tenant1), - createPiece(holdingId1, tenant2), - createPiece(holdingId2, tenant2) - ), - 2, 4 - ), - Arguments.of( - "Pieces with null tenant - converted to empty string", - List.of( - createPiece(holdingId1, null), - createPiece(holdingId1, null) - ), - 1, 1 - ), - Arguments.of( - "Mixed null and non-null tenants", - List.of( - createPiece(holdingId1, tenant1), - createPiece(holdingId1, null) - ), - 2, 2 - ) - ); - } - - @Test - void testGroupPiecesByTenantIdAndHoldingIdWithNullList() { - var result = holdingDetailService.groupPiecesByTenantIdAndHoldingId(null); - - assertNotNull(result); - assertTrue(result.isEmpty()); - } - - @Test - void testGroupPiecesByTenantIdAndHoldingIdFilterNullPieces() { - var pieces = new ArrayList(); - pieces.add(null); - pieces.add(createPiece(UUID.randomUUID().toString(), "tenant-1")); - pieces.add(null); - - var result = holdingDetailService.groupPiecesByTenantIdAndHoldingId(pieces); - - assertNotNull(result); - assertEquals(1, result.size()); - } - - @Test - void testGroupPiecesByTenantIdAndHoldingIdFilterNullHoldingId() { - var pieces = List.of( - createPiece(null, "tenant-1"), - createPiece(UUID.randomUUID().toString(), "tenant-1") - ); - - var result = holdingDetailService.groupPiecesByTenantIdAndHoldingId(pieces); - - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(1, result.get("tenant-1").size()); - } - - @Test - void testGroupPiecesByTenantIdAndHoldingIdNullTenantIdConversion() { - var holdingId = UUID.randomUUID().toString(); - var pieces = List.of(createPiece(holdingId, null)); - - var result = holdingDetailService.groupPiecesByTenantIdAndHoldingId(pieces); - - assertNotNull(result); - assertEquals(1, result.size()); - assertTrue(result.containsKey("")); - assertEquals(1, result.get("").size()); - assertTrue(result.get("").containsKey(holdingId)); - } - - @Test - void testGetHolderFuture(VertxTestContext vertxTestContext) { - var tenantId = "tenant-1"; - var holdingId = UUID.randomUUID().toString(); - var itemId = UUID.randomUUID().toString(); - var poLineId = UUID.randomUUID().toString(); - - var poLinesDetails = List.of( - new org.folio.rest.jaxrs.model.PoLinesDetail() - .withId(poLineId) - ); - - var piecesDetail = List.of( - new org.folio.rest.jaxrs.model.PiecesDetail() - .withId(UUID.randomUUID().toString()) - .withItemId(itemId) - .withTenantId(tenantId) - ); - - var item = new JsonObject() - .put("id", itemId) - .put("holdingsRecordId", holdingId); - - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(List.of(item))); - - var future = holdingDetailService.getHolderFuture(tenantId, holdingId, poLinesDetails, piecesDetail, requestContext); - - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - var holder = result.result(); - assertNotNull(holder); - assertEquals(holdingId, holder.holdingId()); - assertEquals(1, holder.poLines().size()); - assertEquals(poLineId, holder.poLines().getFirst().getId()); - assertEquals(1, holder.pieces().size()); - assertEquals(1, holder.items().size()); - assertEquals(itemId, holder.items().getFirst().getId()); - assertEquals(tenantId, holder.items().getFirst().getTenantId()); - vertxTestContext.completeNow(); - }); - } - - @Test - void testGetHolderFutureWithEmptyTenantId(VertxTestContext vertxTestContext) { - var tenantId = ""; - var holdingId = UUID.randomUUID().toString(); - var poLinesDetails = Collections.emptyList(); - var piecesDetail = Collections.emptyList(); - - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(Collections.emptyList())); - - var future = holdingDetailService.getHolderFuture(tenantId, holdingId, poLinesDetails, piecesDetail, requestContext); - - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - verify(inventoryItemManager).getItemsByHoldingId(eq(holdingId), any(RequestContext.class)); - vertxTestContext.completeNow(); - }); - } - - @Test - void testGetHolderFutureWithNullItemsList(VertxTestContext vertxTestContext) { - var tenantId = "tenant-1"; - var holdingId = UUID.randomUUID().toString(); - var poLinesDetails = List.of( - new org.folio.rest.jaxrs.model.PoLinesDetail() - .withId(UUID.randomUUID().toString()) - ); - var piecesDetail = List.of( - new org.folio.rest.jaxrs.model.PiecesDetail() - .withId(UUID.randomUUID().toString()) - .withTenantId(tenantId) - ); - - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(null)); - - var future = holdingDetailService.getHolderFuture(tenantId, holdingId, poLinesDetails, piecesDetail, requestContext); - - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - var holder = result.result(); - assertNotNull(holder); - assertEquals(holdingId, holder.holdingId()); - assertEquals(1, holder.poLines().size()); - assertEquals(1, holder.pieces().size()); - assertEquals(0, holder.items().size()); // null items should return empty list - vertxTestContext.completeNow(); - }); - } - - @Test - void testGetHolderFutureWithNullPieces(VertxTestContext vertxTestContext) { - var tenantId = "tenant-1"; - var holdingId = UUID.randomUUID().toString(); - - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(Collections.emptyList())); - - var future = holdingDetailService.getHolderFuture(tenantId, holdingId, null, null, requestContext); - - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - var holder = result.result(); - assertNotNull(holder); - assertEquals(holdingId, holder.holdingId()); - assertEquals(0, holder.poLines().size()); // null poLinesDetails should be empty list - assertEquals(0, holder.pieces().size()); // null piecesDetail should be empty list - assertEquals(0, holder.items().size()); - vertxTestContext.completeNow(); - }); - } - - @Test - void testGetHolderFutureFilterNullItems(VertxTestContext vertxTestContext) { - var tenantId = "tenant-1"; - var holdingId = UUID.randomUUID().toString(); - var poLinesDetails = Collections.emptyList(); - var piecesDetail = Collections.emptyList(); - - var items = new ArrayList(); - items.add(null); - items.add(new JsonObject().put("id", UUID.randomUUID().toString())); - items.add(null); - - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(items)); - - var future = holdingDetailService.getHolderFuture(tenantId, holdingId, poLinesDetails, piecesDetail, requestContext); - - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - var holder = result.result(); - assertEquals(1, holder.items().size()); - vertxTestContext.completeNow(); - }); - } - - @Test - void testPostOrdersHoldingDetailPieceStorageFailure(VertxTestContext vertxTestContext) { + @MethodSource("provideServiceFailureScenarios") + void testServiceFailureGracefulDegradation(String testName, boolean poLinesFail, boolean piecesFail, boolean itemsFail, + int expectedPoLines, int expectedPieces, int expectedItems, + VertxTestContext vertxTestContext) { var holdingId = UUID.randomUUID().toString(); var holdingIds = List.of(holdingId); var poLineId = UUID.randomUUID().toString(); - var errorMessage = "Storage service error"; - var poLine = createPoLine(poLineId, holdingId); + var piece = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId) + .withHoldingId(holdingId) + .withReceivingTenantId("tenant-1"); when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) - .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) - .thenReturn(Future.failedFuture(new RuntimeException(errorMessage))); + .thenReturn(poLinesFail ? Future.failedFuture(new RuntimeException("PoLine service error")) + : Future.succeededFuture(List.of(poLine))); + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) + .thenReturn(piecesFail ? Future.failedFuture(new RuntimeException("Piece service error")) + : Future.succeededFuture(List.of(piece))); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) + .thenReturn(itemsFail ? Future.failedFuture(new RuntimeException("Inventory service error")) + : Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); - vertxTestContext.assertFailure(future) + vertxTestContext.assertComplete(future) .onComplete(result -> { - assertTrue(result.failed()); - assertTrue(result.cause().getMessage().contains(errorMessage)); + assertTrue(result.succeeded(), "Should succeed with graceful degradation"); + var holdingDetailResults = result.result(); + assertNotNull(holdingDetailResults); + var property = holdingDetailResults.getAdditionalProperties().get(holdingId); + assertNotNull(property); + assertEquals(expectedPoLines, property.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(expectedPieces, property.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(expectedItems, property.getItemsDetailCollection().getItemsDetail().size()); vertxTestContext.completeNow(); }); } + static Stream provideServiceFailureScenarios() { + return Stream.of( + Arguments.of("All services fail", true, true, true, 0, 0, 0), + Arguments.of("Only pieces service fails", false, true, false, 1, 0, 0), + Arguments.of("Only inventory service fails", false, false, true, 1, 1, 0), + Arguments.of("PoLines and pieces fail", true, true, false, 0, 0, 0) + ); + } + @Test void testCreateItemDetailWithNullItem(VertxTestContext vertxTestContext) { var holdingId = UUID.randomUUID().toString(); @@ -642,13 +395,13 @@ void testCreateItemDetailWithNullItem(VertxTestContext vertxTestContext) { // Create a list with a null item var items = new ArrayList(); items.add(null); - items.add(new JsonObject().put("id", UUID.randomUUID().toString())); + items.add(new JsonObject().put("id", UUID.randomUUID().toString()).put("holdingsRecordId", holdingId)); when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(items)); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -680,8 +433,10 @@ void testPostOrdersHoldingDetailWithNullGroupedPieces(VertxTestContext vertxTest when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -690,9 +445,12 @@ void testPostOrdersHoldingDetailWithNullGroupedPieces(VertxTestContext vertxTest assertTrue(result.succeeded()); var holdingDetailResults = result.result(); // Since piece has null holdingId, it gets filtered during grouping - // No holders are created, so we get empty results (because pieces exist, just all filtered) - assertEquals(0, holdingDetailResults.getAdditionalProperties().size()); - verify(inventoryItemManager, never()).getItemsByHoldingId(anyString(), any()); + // With new parallel execution, we still create results for all requested holdingIds + assertEquals(1, holdingDetailResults.getAdditionalProperties().size()); + var property = holdingDetailResults.getAdditionalProperties().get(holdingId); + assertEquals(1, property.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(0, property.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(0, property.getItemsDetailCollection().getItemsDetail().size()); vertxTestContext.completeNow(); }); } @@ -713,92 +471,98 @@ void testPostOrdersHoldingDetailInventoryFailure(VertxTestContext vertxTestConte when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.failedFuture(new RuntimeException(errorMessage))); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); - vertxTestContext.assertFailure(future) - .onComplete(result -> { - assertTrue(result.failed()); - assertNotNull(result.cause()); - vertxTestContext.completeNow(); - }); - } - - @Test - void testCreatePieceDetailWithMissingItemId(VertxTestContext vertxTestContext) { - var holdingId = UUID.randomUUID().toString(); - var holdingIds = List.of(holdingId); - var poLineId = UUID.randomUUID().toString(); - - var poLine = createPoLine(poLineId, holdingId); - var piece1 = new Piece() - .withId(UUID.randomUUID().toString()) - .withPoLineId(poLineId) - .withHoldingId(holdingId) - .withItemId(null) // null itemId - .withReceivingTenantId("tenant-1"); - - var piece2 = new Piece() - .withId(UUID.randomUUID().toString()) - .withPoLineId(poLineId) - .withHoldingId(holdingId) - .withItemId(UUID.randomUUID().toString()) - .withReceivingTenantId("tenant-1"); - - when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) - .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) - .thenReturn(Future.succeededFuture(List.of(piece1, piece2))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) - .thenReturn(Future.succeededFuture(Collections.emptyList())); - - var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); - + // With parallel execution and error recovery, inventory failure should be handled gracefully vertxTestContext.assertComplete(future) .onComplete(result -> { - assertTrue(result.succeeded()); + assertTrue(result.succeeded(), "Should succeed with graceful degradation"); var holdingDetailResults = result.result(); - assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId)); + assertNotNull(holdingDetailResults); + // Should have poLines and pieces, but no items var property = holdingDetailResults.getAdditionalProperties().get(holdingId); - assertEquals(2, property.getPiecesDetailCollection().getPiecesDetail().size()); - // Verify both pieces are included even with null itemId - var piecesDetail = property.getPiecesDetailCollection().getPiecesDetail(); - assertTrue(piecesDetail.stream().anyMatch(pd -> pd.getItemId() == null)); - assertTrue(piecesDetail.stream().anyMatch(pd -> pd.getItemId() != null)); + assertNotNull(property); + assertEquals(1, property.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(1, property.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(0, property.getItemsDetailCollection().getItemsDetail().size()); vertxTestContext.completeNow(); }); } - @Test - void testCreatePieceDetailWithMissingPieceId(VertxTestContext vertxTestContext) { + @ParameterizedTest(name = "{0}") + @MethodSource("providePieceNullScenarios") + void testPieceHandlingWithNulls(String testName, String scenarioType, int expectedPieceCount, + boolean expectNullId, boolean expectNonNullId, VertxTestContext vertxTestContext) { var holdingId = UUID.randomUUID().toString(); var holdingIds = List.of(holdingId); var poLineId = UUID.randomUUID().toString(); - var poLine = createPoLine(poLineId, holdingId); - var piece1 = new Piece() - .withId(null) // null piece id - .withPoLineId(poLineId) - .withHoldingId(holdingId) - .withItemId(UUID.randomUUID().toString()) - .withReceivingTenantId("tenant-1"); - var piece2 = new Piece() - .withId(UUID.randomUUID().toString()) - .withPoLineId(poLineId) - .withHoldingId(holdingId) - .withItemId(UUID.randomUUID().toString()) - .withReceivingTenantId("tenant-1"); + // Create pieces based on scenario type + var pieces = switch (scenarioType) { + case "NULL_ITEM_ID" -> { + var pieceWithNullItemId = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId) + .withHoldingId(holdingId) + .withItemId(null) + .withReceivingTenantId("tenant-1"); + var pieceWithItemId = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId) + .withHoldingId(holdingId) + .withItemId(UUID.randomUUID().toString()) + .withReceivingTenantId("tenant-1"); + yield List.of(pieceWithNullItemId, pieceWithItemId); + } + case "NULL_PIECE_ID" -> { + var pieceWithNullId = new Piece() + .withId(null) + .withPoLineId(poLineId) + .withHoldingId(holdingId) + .withItemId(UUID.randomUUID().toString()) + .withReceivingTenantId("tenant-1"); + var pieceWithId = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId) + .withHoldingId(holdingId) + .withItemId(UUID.randomUUID().toString()) + .withReceivingTenantId("tenant-1"); + yield List.of(pieceWithNullId, pieceWithId); + } + case "MIXED_NULL" -> { + var validPiece = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId) + .withHoldingId(holdingId) + .withItemId(UUID.randomUUID().toString()) + .withReceivingTenantId("tenant-1"); + var mixedPieces = new ArrayList(); + mixedPieces.add(null); + mixedPieces.add(validPiece); + mixedPieces.add(null); + yield mixedPieces; + } + case "ALL_NULL" -> { + var allNullPieces = new ArrayList(); + allNullPieces.add(null); + allNullPieces.add(null); + allNullPieces.add(null); + yield allNullPieces; + } + default -> throw new IllegalArgumentException("Unknown scenario: " + scenarioType); + }; when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) - .thenReturn(Future.succeededFuture(List.of(piece1, piece2))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(pieces)); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -809,39 +573,39 @@ void testCreatePieceDetailWithMissingPieceId(VertxTestContext vertxTestContext) var holdingDetailResults = result.result(); assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId)); var property = holdingDetailResults.getAdditionalProperties().get(holdingId); - assertEquals(2, property.getPiecesDetailCollection().getPiecesDetail().size()); - // Verify both pieces are included even with null id - var piecesDetail = property.getPiecesDetailCollection().getPiecesDetail(); - assertTrue(piecesDetail.stream().anyMatch(pd -> pd.getId() == null)); - assertTrue(piecesDetail.stream().anyMatch(pd -> pd.getId() != null)); + assertEquals(expectedPieceCount, property.getPiecesDetailCollection().getPiecesDetail().size()); + + if (expectedPieceCount > 0) { + var piecesDetail = property.getPiecesDetailCollection().getPiecesDetail(); + if (expectNullId) { + assertTrue(piecesDetail.stream().anyMatch(pd -> pd.getId() == null || pd.getItemId() == null)); + } + if (expectNonNullId) { + assertTrue(piecesDetail.stream().anyMatch(pd -> pd.getId() != null || pd.getItemId() != null)); + } + } vertxTestContext.completeNow(); }); } - @Test - void testCreatePieceDetailWithMixedNullPieces(VertxTestContext vertxTestContext) { - var holdingId = UUID.randomUUID().toString(); - var holdingIds = List.of(holdingId); - var poLineId = UUID.randomUUID().toString(); - - var poLine = createPoLine(poLineId, holdingId); - var validPiece = new Piece() - .withId(UUID.randomUUID().toString()) - .withPoLineId(poLineId) - .withHoldingId(holdingId) - .withItemId(UUID.randomUUID().toString()) - .withReceivingTenantId("tenant-1"); - - var pieces = new ArrayList(); - pieces.add(null); // null piece - pieces.add(validPiece); - pieces.add(null); // another null piece + static Stream providePieceNullScenarios() { + return Stream.of( + Arguments.of("Pieces with null itemId", "NULL_ITEM_ID", 2, true, true), + Arguments.of("Pieces with null piece ID", "NULL_PIECE_ID", 2, true, true), + Arguments.of("Mixed null and valid pieces", "MIXED_NULL", 1, false, true), + Arguments.of("All null pieces", "ALL_NULL", 0, false, false) + ); + } + @ParameterizedTest(name = "{0}") + @MethodSource("providePoLineGroupingScenarios") + void testGroupedPoLinesByHoldingId(String testName, List holdingIds, List poLines, + int expectedHoldingsWithPoLines, VertxTestContext vertxTestContext) { when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) - .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) - .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + .thenReturn(Future.succeededFuture(poLines)); + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -850,43 +614,105 @@ void testCreatePieceDetailWithMixedNullPieces(VertxTestContext vertxTestContext) .onComplete(result -> { assertTrue(result.succeeded()); var holdingDetailResults = result.result(); - assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId)); - var property = holdingDetailResults.getAdditionalProperties().get(holdingId); - // Only the valid piece should be included - assertEquals(1, property.getPiecesDetailCollection().getPiecesDetail().size()); - assertEquals(validPiece.getId(), property.getPiecesDetailCollection().getPiecesDetail().getFirst().getId()); + assertNotNull(holdingDetailResults); + + // Count how many holdingIds actually have poLines + var holdingsWithPoLines = holdingDetailResults.getAdditionalProperties().values().stream() + .filter(prop -> !prop.getPoLinesDetailCollection().getPoLinesDetail().isEmpty()) + .count(); + assertEquals(expectedHoldingsWithPoLines, holdingsWithPoLines); vertxTestContext.completeNow(); }); } - @Test - void testCreatePieceDetailWithAllNullPieces(VertxTestContext vertxTestContext) { - var holdingId = UUID.randomUUID().toString(); - var holdingIds = List.of(holdingId); - - var poLine = createPoLine(UUID.randomUUID().toString(), holdingId); - // Create a list with only null pieces - var pieces = new ArrayList(); - pieces.add(null); - pieces.add(null); - pieces.add(null); + static Stream providePoLineGroupingScenarios() { + var holdingId1 = UUID.randomUUID().toString(); + var holdingId2 = UUID.randomUUID().toString(); + var holdingId3 = UUID.randomUUID().toString(); + var poLineId1 = UUID.randomUUID().toString(); + var poLineId2 = UUID.randomUUID().toString(); - when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) - .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLine.getId()), requestContext)) - .thenReturn(Future.succeededFuture(pieces)); + // Create list with null poLine + var poLinesWithNull = new ArrayList(); + poLinesWithNull.add(null); - var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); + // Create mixed list with null and valid poLines + var mixedPoLines = new ArrayList(); + mixedPoLines.add(null); + mixedPoLines.add(createPoLine(poLineId1, holdingId1)); + mixedPoLines.add(new PoLine().withId(poLineId2).withLocations(List.of(new Location().withHoldingId(null)))); - vertxTestContext.assertComplete(future) - .onComplete(result -> { - assertTrue(result.succeeded()); - var holdingDetailResults = result.result(); - // All pieces are null, after filtering we get empty grouping, so no holders created - assertEquals(0, holdingDetailResults.getAdditionalProperties().size()); - verify(inventoryItemManager, never()).getItemsByHoldingId(anyString(), any()); - vertxTestContext.completeNow(); - }); + return Stream.of( + Arguments.of("Empty poLines list", List.of(holdingId1), Collections.emptyList(), 0), + + Arguments.of("Null poLine in list", List.of(holdingId1), poLinesWithNull, 0), + + Arguments.of("PoLine with null locations", + List.of(holdingId1), + List.of(new PoLine().withId(poLineId1).withLocations(null)), + 0), + + Arguments.of("PoLine with empty locations", + List.of(holdingId1), + List.of(new PoLine().withId(poLineId1).withLocations(Collections.emptyList())), + 0), + + Arguments.of("Location with null holdingId", + List.of(holdingId1), + List.of(new PoLine().withId(poLineId1).withLocations(List.of(new Location().withHoldingId(null)))), + 0), + + Arguments.of("Location with holdingId not in request", + List.of(holdingId1), + List.of(new PoLine().withId(poLineId1).withLocations(List.of(new Location().withHoldingId(holdingId2)))), + 0), + + Arguments.of("Valid single poLine", + List.of(holdingId1), + List.of(createPoLine(poLineId1, holdingId1)), + 1), + + Arguments.of("Multiple poLines same holding", + List.of(holdingId1), + List.of(createPoLine(poLineId1, holdingId1), createPoLine(poLineId2, holdingId1)), + 1), + + Arguments.of("Multiple poLines different holdings", + List.of(holdingId1, holdingId2), + List.of(createPoLine(poLineId1, holdingId1), createPoLine(poLineId2, holdingId2)), + 2), + + Arguments.of("PoLine with multiple locations same holding", + List.of(holdingId1), + List.of(new PoLine().withId(poLineId1).withLocations(List.of( + new Location().withHoldingId(holdingId1), + new Location().withHoldingId(holdingId1) + ))), + 1), + + Arguments.of("PoLine with multiple locations different holdings", + List.of(holdingId1, holdingId2), + List.of(new PoLine().withId(poLineId1).withLocations(List.of( + new Location().withHoldingId(holdingId1), + new Location().withHoldingId(holdingId2) + ))), + 2), + + Arguments.of("Mixed: null poLine, valid poLine, poLine with null location", + List.of(holdingId1, holdingId2), + mixedPoLines, + 1), + + Arguments.of("PoLine with locations containing nulls and valid holdingId", + List.of(holdingId1, holdingId2), + List.of(new PoLine().withId(poLineId1).withLocations(List.of( + new Location().withHoldingId(null), + new Location().withHoldingId(holdingId1), + new Location().withHoldingId(holdingId3), // not in requested holdingIds + new Location().withHoldingId(holdingId2) + ))), + 2) + ); } @Test @@ -905,9 +731,9 @@ void testCreatePoLineDetailWithSinglePiece(VertxTestContext vertxTestContext) { when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -951,9 +777,9 @@ void testCreatePoLineDetailWithDuplicatePoLineIdSameTenant(VertxTestContext vert when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -995,9 +821,9 @@ void testCreatePoLineDetailWithSamePoLineIdDifferentTenants(VertxTestContext ver // Need to setup expectations for both tenants when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1053,9 +879,9 @@ void testCreatePoLineDetailWithMultipleDifferentPoLines(VertxTestContext vertxTe when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine1, poLine2, poLine3))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId1, poLineId2, poLineId3), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1096,9 +922,9 @@ void testCreatePoLineDetailWithNullPoLineId(VertxTestContext vertxTestContext) { when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1110,11 +936,10 @@ void testCreatePoLineDetailWithNullPoLineId(VertxTestContext vertxTestContext) { assertTrue(additionalProps.containsKey(holdingId)); var property = additionalProps.get(holdingId); var poLinesDetail = property.getPoLinesDetailCollection().getPoLinesDetail(); - // PoLines are created from pieces using createPoLineDetailFromPieces - // 1 piece with null poLineId + 1 piece with poLineId = 2 PoLinesDetail (one null, one with id) - assertEquals(2, poLinesDetail.size()); - assertTrue(poLinesDetail.stream().anyMatch(pl -> pl.getId() == null)); - assertTrue(poLinesDetail.stream().anyMatch(pl -> poLineId.equals(pl.getId()))); + // PoLinesDetail are created from poLines fetched, not from pieces + // We have 1 poLine, so 1 PoLinesDetail + assertEquals(1, poLinesDetail.size()); + assertEquals(poLineId, poLinesDetail.getFirst().getId()); // Both pieces should be included assertEquals(2, property.getPiecesDetailCollection().getPiecesDetail().size()); vertxTestContext.completeNow(); @@ -1136,9 +961,9 @@ void testCreatePoLineDetailWithNullTenantId(VertxTestContext vertxTestContext) { when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(piece))); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1206,9 +1031,9 @@ void testCreatePoLineDetailComplexScenario(VertxTestContext vertxTestContext) { when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine1, poLine2))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId1, poLineId2), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1259,9 +1084,9 @@ void testCreatePoLineDetailWithMixedNullPoLineIds(VertxTestContext vertxTestCont when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1273,10 +1098,10 @@ void testCreatePoLineDetailWithMixedNullPoLineIds(VertxTestContext vertxTestCont assertTrue(additionalProps.containsKey(holdingId)); var property = additionalProps.get(holdingId); var poLinesDetail = property.getPoLinesDetailCollection().getPoLinesDetail(); - // PoLines created from pieces: 2 null poLineIds (distinct = 1) + 1 poLineId = 2 total - assertEquals(2, poLinesDetail.size()); - assertEquals(1, poLinesDetail.stream().filter(pl -> pl.getId() == null).count()); - assertEquals(1, poLinesDetail.stream().filter(pl -> poLineId.equals(pl.getId())).count()); + // PoLinesDetail are created from poLines fetched, not from pieces + // We have 1 poLine, so 1 PoLinesDetail + assertEquals(1, poLinesDetail.size()); + assertEquals(poLineId, poLinesDetail.getFirst().getId()); // All 3 pieces should be included assertEquals(3, property.getPiecesDetailCollection().getPiecesDetail().size()); vertxTestContext.completeNow(); @@ -1308,9 +1133,9 @@ void testPoLinesDetailCollectionTotalRecordsAccuracy(VertxTestContext vertxTestC when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(List.of(poLine1, poLine2))); - when(pieceStorageService.getPiecesByLineIdsByChunks(List.of(poLineId1, poLineId2), requestContext)) + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(pieces)); - when(inventoryItemManager.getItemsByHoldingId(eq(holdingId), any(RequestContext.class))) + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) .thenReturn(Future.succeededFuture(Collections.emptyList())); var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); @@ -1326,12 +1151,438 @@ void testPoLinesDetailCollectionTotalRecordsAccuracy(VertxTestContext vertxTestC }); } - private static Piece createPiece(String holdingId, String tenantId) { - return new Piece() + @ParameterizedTest(name = "checkinItems = {0}") + @MethodSource("provideCheckinItemsValues") + void testPoLinesDetailWithCheckinItems(Boolean checkinItems, VertxTestContext vertxTestContext) { + var holdingId = UUID.randomUUID().toString(); + var holdingIds = List.of(holdingId); + var poLineId = UUID.randomUUID().toString(); + + var poLine = new PoLine() + .withId(poLineId) + .withCheckinItems(checkinItems) + .withLocations(List.of(new Location().withHoldingId(holdingId))); + + when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(List.of(poLine))); + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + + var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + var property = result.result().getAdditionalProperties().get(holdingId); + var poLinesDetail = property.getPoLinesDetailCollection().getPoLinesDetail(); + assertEquals(1, poLinesDetail.size()); + assertEquals(poLineId, poLinesDetail.getFirst().getId()); + assertEquals(checkinItems, poLinesDetail.getFirst().getCheckinItems()); + vertxTestContext.completeNow(); + }); + } + + static Stream provideCheckinItemsValues() { + return Stream.of( + Arguments.of(true), + Arguments.of(false), + Arguments.of((Boolean) null) + ); + } + + // ===== Central Ordering / Consortium Tests ===== + // IMPORTANT: Holdings are tenant-specific resources in FOLIO. + // In a consortium with multiple member tenants, each tenant has its own separate holdings. + // A holding ID in tenant A is a completely different resource than a holding ID in tenant B, + // even if they happen to have the same UUID value. + // Therefore, tests should use DIFFERENT holding IDs for DIFFERENT tenants to reflect reality. + + @Test + // Test that when central ordering is enabled and multiple tenants have data, + // each tenant's holdings are retrieved and returned separately in the results + void testCentralOrderingEnabledWithMultipleTenants(VertxTestContext vertxTestContext) { + var holdingId1 = UUID.randomUUID().toString(); + var holdingId2 = UUID.randomUUID().toString(); + var holdingIds = List.of(holdingId1, holdingId2); + var consortiumId = UUID.randomUUID().toString(); + var centralTenantId = "central-tenant"; + var memberTenant1 = "member-tenant-1"; + var memberTenant2 = "member-tenant-2"; + var userTenants = List.of(memberTenant1, memberTenant2); + + // Setup consortium configuration + var consortiumConfig = new ConsortiumConfiguration(centralTenantId, consortiumId); + when(consortiumConfigurationService.getConsortiumConfiguration(requestContext)) + .thenReturn(Future.succeededFuture(java.util.Optional.of(consortiumConfig))); + + // Setup central ordering setting + doReturn(Future.succeededFuture(java.util.Optional.of(new Setting().withValue("true")))) + .when(settingsRetriever).getSettingByKey(any(), any()); + + // Setup user tenants retrieval + when(consortiumUserTenantsRetriever.getUserTenants(consortiumId, centralTenantId, requestContext)) + .thenReturn(Future.succeededFuture(userTenants)); + + // Create test data for member-tenant-1 + var poLine1Tenant1 = createPoLine(UUID.randomUUID().toString(), holdingId1); + var piece1Tenant1 = new Piece() .withId(UUID.randomUUID().toString()) - .withHoldingId(holdingId) + .withPoLineId(poLine1Tenant1.getId()) + .withHoldingId(holdingId1) + .withReceivingTenantId(memberTenant1); + var item1Tenant1 = new JsonObject() + .put("id", UUID.randomUUID().toString()) + .put("holdingsRecordId", holdingId1); + + // Create test data for member-tenant-2 + var poLine1Tenant2 = createPoLine(UUID.randomUUID().toString(), holdingId2); + var piece1Tenant2 = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLine1Tenant2.getId()) + .withHoldingId(holdingId2) + .withReceivingTenantId(memberTenant2); + var item1Tenant2 = new JsonObject() + .put("id", UUID.randomUUID().toString()) + .put("holdingsRecordId", holdingId2); + + // Mock responses for member-tenant-1 context + when(purchaseOrderLineService.getPoLinesByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(poLine1Tenant1)); + } else if (memberTenant2.equals(tenant)) { + return Future.succeededFuture(List.of(poLine1Tenant2)); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + when(pieceStorageService.getPiecesByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(piece1Tenant1)); + } else if (memberTenant2.equals(tenant)) { + return Future.succeededFuture(List.of(piece1Tenant2)); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + when(inventoryItemManager.getItemsByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(item1Tenant1)); + } else if (memberTenant2.equals(tenant)) { + return Future.succeededFuture(List.of(item1Tenant2)); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + var holdingDetailResults = result.result(); + assertNotNull(holdingDetailResults); + + // Both holdings should be present + assertEquals(2, holdingDetailResults.getAdditionalProperties().size()); + assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId1)); + assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId2)); + + // Verify holdingId1 has data from tenant1 + var property1 = holdingDetailResults.getAdditionalProperties().get(holdingId1); + assertEquals(1, property1.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(1, property1.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(1, property1.getItemsDetailCollection().getItemsDetail().size()); + assertEquals(memberTenant1, property1.getPiecesDetailCollection().getPiecesDetail().getFirst().getTenantId()); + + // Verify holdingId2 has data from tenant2 + var property2 = holdingDetailResults.getAdditionalProperties().get(holdingId2); + assertEquals(1, property2.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(1, property2.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(1, property2.getItemsDetailCollection().getItemsDetail().size()); + assertEquals(memberTenant2, property2.getPiecesDetailCollection().getPiecesDetail().getFirst().getTenantId()); + + vertxTestContext.completeNow(); + }); + } + + @Test + // Test that when multiple tenants have data for their respective holdings, + // the results are properly aggregated with each holding maintaining its tenant-specific data + void testCentralOrderingWithMultipleTenantsAndOverlappingData(VertxTestContext vertxTestContext) { + // Holdings are tenant-specific, so each tenant has its own holding IDs + var holdingId1 = UUID.randomUUID().toString(); // holding in tenant1 + var holdingId2 = UUID.randomUUID().toString(); // holding in tenant2 + var holdingIds = List.of(holdingId1, holdingId2); + var consortiumId = UUID.randomUUID().toString(); + var centralTenantId = "central-tenant"; + var memberTenant1 = "member-tenant-1"; + var memberTenant2 = "member-tenant-2"; + var userTenants = List.of(memberTenant1, memberTenant2); + + // Setup consortium configuration + var consortiumConfig = new ConsortiumConfiguration(centralTenantId, consortiumId); + when(consortiumConfigurationService.getConsortiumConfiguration(requestContext)) + .thenReturn(Future.succeededFuture(java.util.Optional.of(consortiumConfig))); + + doReturn(Future.succeededFuture(java.util.Optional.of(new Setting().withValue("true")))) + .when(settingsRetriever).getSettingByKey(any(), any()); + + when(consortiumUserTenantsRetriever.getUserTenants(consortiumId, centralTenantId, requestContext)) + .thenReturn(Future.succeededFuture(userTenants)); + + // Each tenant has data for their own holding + var poLineId1 = UUID.randomUUID().toString(); + var poLineId2 = UUID.randomUUID().toString(); + var poLine1 = createPoLine(poLineId1, holdingId1); + var poLine2 = createPoLine(poLineId2, holdingId2); + + var piece1 = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId1) + .withHoldingId(holdingId1) .withItemId(UUID.randomUUID().toString()) - .withReceivingTenantId(tenantId); + .withReceivingTenantId(memberTenant1); + + var piece2 = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLineId2) + .withHoldingId(holdingId2) + .withItemId(UUID.randomUUID().toString()) + .withReceivingTenantId(memberTenant2); + + var item1 = new JsonObject() + .put("id", piece1.getItemId()) + .put("holdingsRecordId", holdingId1); + + var item2 = new JsonObject() + .put("id", piece2.getItemId()) + .put("holdingsRecordId", holdingId2); + + when(purchaseOrderLineService.getPoLinesByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + // Each tenant only returns data for their own holdings + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(poLine1)); + } else if (memberTenant2.equals(tenant)) { + return Future.succeededFuture(List.of(poLine2)); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + when(pieceStorageService.getPiecesByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(piece1)); + } else if (memberTenant2.equals(tenant)) { + return Future.succeededFuture(List.of(piece2)); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + when(inventoryItemManager.getItemsByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(item1)); + } else if (memberTenant2.equals(tenant)) { + return Future.succeededFuture(List.of(item2)); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + var holdingDetailResults = result.result(); + assertNotNull(holdingDetailResults); + System.out.println(JsonObject.mapFrom(holdingDetailResults).encodePrettily()); + + // Should have 2 holdings, one from each tenant + assertEquals(2, holdingDetailResults.getAdditionalProperties().size()); + assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId1)); + assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId2)); + System.out.println(JsonObject.mapFrom(holdingDetailResults).encodePrettily()); + + // Verify holding1 has data from tenant1 + var property1 = holdingDetailResults.getAdditionalProperties().get(holdingId1); + assertNotNull(property1); + assertEquals(1, property1.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(1, property1.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(1, property1.getItemsDetailCollection().getItemsDetail().size()); + assertEquals(memberTenant1, property1.getPiecesDetailCollection().getPiecesDetail().getFirst().getTenantId()); + assertEquals(memberTenant1, property1.getItemsDetailCollection().getItemsDetail().getFirst().getTenantId()); + assertEquals(poLineId1, property1.getPoLinesDetailCollection().getPoLinesDetail().getFirst().getId()); + + // Verify holding2 has data from tenant2 + var property2 = holdingDetailResults.getAdditionalProperties().get(holdingId2); + assertNotNull(property2); + assertEquals(1, property2.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(1, property2.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(1, property2.getItemsDetailCollection().getItemsDetail().size()); + assertEquals(memberTenant2, property2.getPiecesDetailCollection().getPiecesDetail().getFirst().getTenantId()); + assertEquals(memberTenant2, property2.getItemsDetailCollection().getItemsDetail().getFirst().getTenantId()); + assertEquals(poLineId2, property2.getPoLinesDetailCollection().getPoLinesDetail().getFirst().getId()); + + vertxTestContext.completeNow(); + }); + } + + @Test + void testCentralOrderingDisabled(VertxTestContext vertxTestContext) { + var holdingId = UUID.randomUUID().toString(); + var holdingIds = List.of(holdingId); + var consortiumId = UUID.randomUUID().toString(); + var centralTenantId = "central-tenant"; + + // Consortium exists but central ordering is disabled + var consortiumConfig = new ConsortiumConfiguration(centralTenantId, consortiumId); + when(consortiumConfigurationService.getConsortiumConfiguration(requestContext)) + .thenReturn(Future.succeededFuture(java.util.Optional.of(consortiumConfig))); + + // Central ordering is disabled or not set + when(settingsRetriever.getSettingByKey(any(), any())) + .thenReturn(Future.succeededFuture(java.util.Optional.empty())); + + // Should not call getUserTenants when central ordering is disabled + when(consortiumUserTenantsRetriever.getUserTenants(any(), any(), any())) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + + var poLine = createPoLine(UUID.randomUUID().toString(), holdingId); + when(purchaseOrderLineService.getPoLinesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(List.of(poLine))); + when(pieceStorageService.getPiecesByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + when(inventoryItemManager.getItemsByHoldingIds(holdingIds, requestContext)) + .thenReturn(Future.succeededFuture(Collections.emptyList())); + + var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded()); + var holdingDetailResults = result.result(); + assertNotNull(holdingDetailResults); + assertEquals(1, holdingDetailResults.getAdditionalProperties().size()); + System.out.println(JsonObject.mapFrom(holdingDetailResults).encodePrettily()); + + var property = holdingDetailResults.getAdditionalProperties().get(holdingId); + assertEquals(1, property.getPoLinesDetailCollection().getPoLinesDetail().size()); + + vertxTestContext.completeNow(); + }); + } + + @Test + // Test that when one tenant experiences service failures, we still get results from successful tenants + // This verifies graceful degradation in a consortium environment + void testCentralOrderingWithPartialTenantFailure(VertxTestContext vertxTestContext) { + // Each tenant has its own holdings since holdings are tenant-specific + var holdingId1 = UUID.randomUUID().toString(); // holding in tenant1 + var holdingId2 = UUID.randomUUID().toString(); // holding in tenant2 (but will fail to retrieve) + var holdingIds = List.of(holdingId1, holdingId2); + var consortiumId = UUID.randomUUID().toString(); + var centralTenantId = "central-tenant"; + var memberTenant1 = "member-tenant-1"; + var memberTenant2 = "member-tenant-2"; + var userTenants = List.of(memberTenant1, memberTenant2); + + var consortiumConfig = new ConsortiumConfiguration(centralTenantId, consortiumId); + when(consortiumConfigurationService.getConsortiumConfiguration(requestContext)) + .thenReturn(Future.succeededFuture(java.util.Optional.of(consortiumConfig))); + + doReturn(Future.succeededFuture(java.util.Optional.of(new Setting().withValue("true")))) + .when(settingsRetriever).getSettingByKey(any(), any()); + + when(consortiumUserTenantsRetriever.getUserTenants(consortiumId, centralTenantId, requestContext)) + .thenReturn(Future.succeededFuture(userTenants)); + + var poLine1 = createPoLine(UUID.randomUUID().toString(), holdingId1); + var piece1 = new Piece() + .withId(UUID.randomUUID().toString()) + .withPoLineId(poLine1.getId()) + .withHoldingId(holdingId1) + .withReceivingTenantId(memberTenant1); + + // Tenant 1 succeeds with its holding, Tenant 2 fails for all services (simulating service outage) + when(purchaseOrderLineService.getPoLinesByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(poLine1)); + } else if (memberTenant2.equals(tenant)) { + return Future.failedFuture(new RuntimeException("Tenant 2 poLines service error")); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + when(pieceStorageService.getPiecesByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(List.of(piece1)); + } else if (memberTenant2.equals(tenant)) { + return Future.failedFuture(new RuntimeException("Tenant 2 pieces service error")); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + when(inventoryItemManager.getItemsByHoldingIds(eq(holdingIds), any())) + .thenAnswer(invocation -> { + var ctx = (RequestContext) invocation.getArgument(1); + var tenant = TenantTool.tenantId(ctx.getHeaders()); + if (memberTenant1.equals(tenant)) { + return Future.succeededFuture(Collections.emptyList()); + } else if (memberTenant2.equals(tenant)) { + return Future.failedFuture(new RuntimeException("Tenant 2 items service error")); + } + return Future.succeededFuture(Collections.emptyList()); + }); + + var future = holdingDetailService.postOrdersHoldingDetail(holdingIds, requestContext); + + vertxTestContext.assertComplete(future) + .onComplete(result -> { + assertTrue(result.succeeded(), "Should succeed even with partial tenant failure due to graceful degradation"); + var holdingDetailResults = result.result(); + assertNotNull(holdingDetailResults); + System.out.println(JsonObject.mapFrom(holdingDetailResults).encodePrettily()); + + // Should have holding from tenant1 and tenant2, tenant2 data will consist of empty arrays + assertEquals(2, holdingDetailResults.getAdditionalProperties().size()); + assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId1)); + assertTrue(holdingDetailResults.getAdditionalProperties().containsKey(holdingId2), + "Holding from failed tenant should not be present"); + + var property = holdingDetailResults.getAdditionalProperties().get(holdingId1); + assertNotNull(property); + + // Should have data from tenant1 only, tenant2 failed gracefully + assertEquals(1, property.getPoLinesDetailCollection().getPoLinesDetail().size()); + assertEquals(1, property.getPiecesDetailCollection().getPiecesDetail().size()); + assertEquals(0, property.getItemsDetailCollection().getItemsDetail().size()); + assertEquals(memberTenant1, property.getPiecesDetailCollection().getPiecesDetail().getFirst().getTenantId()); + + vertxTestContext.completeNow(); + }); } private static PoLine createPoLine(String poLineId, String holdingId) {