From 866c3994b647d71bcb87e4f39778d022ac287a85 Mon Sep 17 00:00:00 2001 From: Marco Hamann Date: Fri, 7 Nov 2025 11:18:07 +0100 Subject: [PATCH 1/4] add new notice event type and logic to trigger notice on hold for item --- .../domain/notice/NoticeEventType.java | 1 + .../resources/RequestNoticeSender.java | 46 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/folio/circulation/domain/notice/NoticeEventType.java b/src/main/java/org/folio/circulation/domain/notice/NoticeEventType.java index 7ccf2e0d05..6af99bacda 100644 --- a/src/main/java/org/folio/circulation/domain/notice/NoticeEventType.java +++ b/src/main/java/org/folio/circulation/domain/notice/NoticeEventType.java @@ -13,6 +13,7 @@ public enum NoticeEventType { DUE_DATE("Due date"), ITEM_RECALLED("Item recalled"), AGED_TO_LOST("Aged to lost"), + HOLD_REQUEST_FOR_ITEM("Hold request for item"), // Request notices, PAGING_REQUEST("Paging request"), diff --git a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java index 1b6f34a20a..f9d7ed4b88 100644 --- a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java +++ b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java @@ -1,8 +1,6 @@ package org.folio.circulation.resources; -import static org.folio.circulation.domain.notice.NoticeEventType.AVAILABLE; -import static org.folio.circulation.domain.notice.NoticeEventType.ITEM_RECALLED; -import static org.folio.circulation.domain.notice.NoticeEventType.REQUEST_CANCELLATION; +import static org.folio.circulation.domain.notice.NoticeEventType.*; import static org.folio.circulation.domain.notice.PatronNotice.buildEmail; import static org.folio.circulation.domain.notice.TemplateContextUtil.createLoanNoticeContext; import static org.folio.circulation.domain.notice.TemplateContextUtil.createRequestNoticeContext; @@ -13,10 +11,7 @@ import static org.folio.circulation.support.results.Result.succeeded; import java.lang.invoke.MethodHandles; -import java.util.Collections; -import java.util.EnumMap; -import java.util.Map; -import java.util.UUID; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.function.Function; @@ -227,7 +222,10 @@ private CompletableFuture> sendConfirmationNoticeForRequestWithItem log.debug("sendConfirmationNoticeForRequestWithItem:: parameters request: {}", () -> request); return createPatronNoticeEvent(request, getEventType(request)) .thenCompose(r -> r.after(patronNoticeService::acceptNoticeEvent)) - .whenComplete((r, t) -> sendNoticeOnRecall(request)); + .whenComplete((r, t) -> { + sendNoticeOnRecall(request); + sendHoldNoticeForItemLevelRequest(request); + }); } private CompletableFuture> sendConfirmationNoticeForRequestWithoutItemId( @@ -345,17 +343,43 @@ private CompletableFuture> sendNoticeOnRecall(Request request) { } return fetchAdditionalInfo(request) - .thenCompose(r -> r.after(req -> sendLoanNotice(req.getLoan()))); + .thenCompose(r -> r.after(req -> sendLoanNotice(req.getLoan(), ITEM_RECALLED))); } - private CompletableFuture> sendLoanNotice(Loan updatedLoan){ + /** + * Sends hold notice to the borrower of a specific item + */ + private CompletableFuture> sendHoldNoticeForItemLevelRequest(Request request) { + log.debug("sendHoldNoticeForItemLevelRequest:: sending notice for item: {}", + request.getItemId()); + + return fetchAdditionalInfo(request) + .thenCompose(r -> r.after(req -> { + Loan loan = req.getLoan(); + + if (loan == null || loan.getUser() == null || loan.getItem() == null) { + log.debug("sendHoldNoticeForItemLevelRequest:: no active loan found, skipping notice"); + return ofAsync(null); + } + + if (!loan.isOpen()) { + log.debug("sendHoldNoticeForItemLevelRequest:: loan is not open, skipping notice"); + return ofAsync(null); + } + + return sendLoanNotice(loan, HOLD_REQUEST_FOR_ITEM); + })); + } + + + private CompletableFuture> sendLoanNotice(Loan updatedLoan, NoticeEventType eventType){ return getRecipientId(updatedLoan) .thenCompose(result -> result.after(recipientId -> { var itemRecalledEvent = new PatronNoticeEventBuilder() .withItem(updatedLoan.getItem()) .withUser(updatedLoan.getUser()) .withRecipientId(recipientId) - .withEventType(ITEM_RECALLED) + .withEventType(eventType) .withNoticeContext(createLoanNoticeContext(updatedLoan)) .withNoticeLogContext(NoticeLogContext.from(updatedLoan)) .build(); From e2216e5d30e1d2f812336f6cdf910bc9fcf953cd Mon Sep 17 00:00:00 2001 From: Marco Hamann Date: Fri, 7 Nov 2025 15:47:56 +0100 Subject: [PATCH 2/4] add count on hold, update tests --- .../resources/RequestNoticeSender.java | 15 ++++++++++++--- .../java/api/queue/RequestQueueResourceTest.java | 2 +- .../api/requests/RequestsAPICreationTests.java | 2 +- .../api/requests/scenarios/MoveRequestTests.java | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java index f9d7ed4b88..02c1c4a283 100644 --- a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java +++ b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java @@ -63,6 +63,7 @@ public class RequestNoticeSender { private final ProxyRelationshipValidator proxyRelationshipValidator; private long recallRequestCount = 0L; + private long holdRequestCount = 0L; protected final ImmediatePatronNoticeService patronNoticeService; protected final LocationRepository locationRepository; @@ -112,8 +113,16 @@ public Result sendNoticeOnRequestCreated( Request request = records.getRequest(); recallRequestCount = records.getRequestQueue().getRequests() .stream() - .filter(r -> r.getRequestType() == RequestType.RECALL && r.isNotYetFilled() - && r.getItemId().equals(request.getItemId())) + .filter(r -> r.getRequestType() == RequestType.RECALL + && r.isNotYetFilled() + && Objects.equals(r.getItemId(), request.getItemId())) + .count(); + + holdRequestCount = records.getRequestQueue().getRequests() + .stream() + .filter(r -> r.getRequestType() == RequestType.HOLD + && r.isNotYetFilled() + && Objects.equals(r.getItemId(), request.getItemId())) .count(); if (request.hasItemId()) { @@ -357,7 +366,7 @@ private CompletableFuture> sendHoldNoticeForItemLevelRequest(Reques .thenCompose(r -> r.after(req -> { Loan loan = req.getLoan(); - if (loan == null || loan.getUser() == null || loan.getItem() == null) { + if (!request.isHold() || loan == null || loan.getUser() == null || loan.getItem() == null || holdRequestCount > 1) { log.debug("sendHoldNoticeForItemLevelRequest:: no active loan found, skipping notice"); return ofAsync(null); } diff --git a/src/test/java/api/queue/RequestQueueResourceTest.java b/src/test/java/api/queue/RequestQueueResourceTest.java index 88c90efc05..574725fab4 100644 --- a/src/test/java/api/queue/RequestQueueResourceTest.java +++ b/src/test/java/api/queue/RequestQueueResourceTest.java @@ -612,7 +612,7 @@ void logRecordEventIsPublished(TlrFeatureStatus tlrFeatureStatus) { verifyQueueUpdatedForItem(reorderQueue, response); // TODO: understand why - int numberOfPublishedEvents = 16; + int numberOfPublishedEvents = 17; final var publishedEvents = Awaitility.await() .atMost(1, TimeUnit.SECONDS) .until(FakePubSub::getPublishedEvents, hasSize(numberOfPublishedEvents)); diff --git a/src/test/java/api/requests/RequestsAPICreationTests.java b/src/test/java/api/requests/RequestsAPICreationTests.java index fa03258081..86060213d8 100644 --- a/src/test/java/api/requests/RequestsAPICreationTests.java +++ b/src/test/java/api/requests/RequestsAPICreationTests.java @@ -2071,7 +2071,7 @@ void canCreateHoldRequestWhenItemIsCheckedOut() { assertThat(holdRequest.getJson().getString("status"), is(RequestStatus.OPEN_NOT_YET_FILLED.getValue())); var publishedEvents = Awaitility.await() .atMost(1, TimeUnit.SECONDS) - .until(FakePubSub::getPublishedEvents, hasSize(5)); + .until(FakePubSub::getPublishedEvents, hasSize(6)); assertThat(publishedEvents.filterToList(byEventType("LOAN_DUE_DATE_CHANGED")), hasSize(1)); } diff --git a/src/test/java/api/requests/scenarios/MoveRequestTests.java b/src/test/java/api/requests/scenarios/MoveRequestTests.java index 67034214db..e6f28b7f25 100644 --- a/src/test/java/api/requests/scenarios/MoveRequestTests.java +++ b/src/test/java/api/requests/scenarios/MoveRequestTests.java @@ -1333,7 +1333,7 @@ itemCopyA, charlotte, getZonedDateTime().minusHours(1), // There should be four events published - for "check out", for "log event", for "hold" and for "move" final var publishedEvents = Awaitility.await() .atMost(1, TimeUnit.SECONDS) - .until(FakePubSub::getPublishedEvents, hasSize(11)); + .until(FakePubSub::getPublishedEvents, hasSize(12)); Map> events = publishedEvents.stream().collect(groupingBy(o -> o.getString("eventType"))); From a0ab88aa4b8a88f540a2877fd2301f49e066f77b Mon Sep 17 00:00:00 2001 From: Marco Hamann Date: Wed, 12 Nov 2025 15:18:00 +0100 Subject: [PATCH 3/4] add logic to sendNotice for titleLevel holdRequest --- .../resources/RequestNoticeSender.java | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java index 02c1c4a283..5d4733d740 100644 --- a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java +++ b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java @@ -14,6 +14,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -43,6 +44,7 @@ import org.folio.circulation.infrastructure.storage.requests.RequestRepository; import org.folio.circulation.infrastructure.storage.users.UserRepository; import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.storage.ItemByInstanceIdFinder; import org.folio.circulation.support.Clients; import org.folio.circulation.support.HttpFailure; import org.folio.circulation.support.RecordNotFoundFailure; @@ -61,6 +63,7 @@ public class RequestNoticeSender { private final ServicePointRepository servicePointRepository; private final EventPublisher eventPublisher; private final ProxyRelationshipValidator proxyRelationshipValidator; + private final ItemByInstanceIdFinder itemByInstanceIdFinder; private long recallRequestCount = 0L; private long holdRequestCount = 0L; @@ -87,6 +90,7 @@ public RequestNoticeSender(Clients clients) { this.eventPublisher = new EventPublisher(clients); this.locationRepository = LocationRepository.using(clients, servicePointRepository); this.proxyRelationshipValidator = new ProxyRelationshipValidator(clients); + this.itemByInstanceIdFinder = new ItemByInstanceIdFinder(clients.holdingsStorage(), itemRepository); } public static RequestNoticeSender using(Clients clients) { @@ -285,7 +289,16 @@ private CompletableFuture> fetchDataAndSendNotice(Request request, () -> request, () -> templateId, () -> eventType); return fetchAdditionalInfo(request) - .thenCompose(r -> r.after(req -> sendNotice(req, templateId, eventType))); + .thenCompose(r -> r.after(req -> { + CompletableFuture> noticeFuture = sendNotice(req, templateId, eventType); + if (req.isHold()) { + CompletableFuture> titleLevelFuture = sendNoticeForTitleLevelRequest(req); + return CompletableFuture.allOf(noticeFuture, titleLevelFuture) + .thenApply(v -> Result.succeeded(null)); + } else { + return noticeFuture; + } + })); } private CompletableFuture> sendNotice(Request request, UUID templateId, @@ -380,6 +393,76 @@ private CompletableFuture> sendHoldNoticeForItemLevelRequest(Reques })); } + private CompletableFuture> sendNoticeForTitleLevelRequest(Request request) { + log.debug("sendNoticeForTitleLevelRequest:: sending notice for item: {}", request.getItemId()); + + return collectOpenLoansForTitleLevel(request) + .thenCompose(loansResult -> { + if (loansResult.failed() || loansResult.value() == null) { + return ofAsync(null); + } + + Collection openLoans = loansResult.value(); + if (openLoans.isEmpty()) { + return ofAsync(null); + } + + List>> noticeFutures = openLoans.stream() + .map(loan -> sendLoanNotice(loan, HOLD_REQUEST_FOR_ITEM)) + .toList(); + + CompletableFuture allDone = + CompletableFuture.allOf(noticeFutures.toArray(new CompletableFuture[0])); + + return allDone.thenApply(r -> Result.succeeded(null)); + }); + } + + + public CompletableFuture>> collectOpenLoansForTitleLevel(Request request) { + + // Step 1: Fetch all items for this title level + return fetchItemsForTitleLevel(request) + .thenCompose(itemsResult -> { + if (itemsResult.failed() || itemsResult.value() == null) { + return CompletableFuture.completedFuture(Result.failed(itemsResult.cause())); + } + + Collection items = itemsResult.value(); + + // Step 2: For each item, find open loan asynchronously + List>> loanFutures = items.stream() + .map(item -> loanRepository.findOpenLoanForItem(item) + .thenApply(loanResult -> { + if (loanResult.succeeded() && loanResult.value() != null) { + return Optional.of(loanResult.value()); + } else { + return Optional.empty(); + } + }) + ) + .toList(); + + // Step 3: Combine all futures + CompletableFuture allDone = + CompletableFuture.allOf(loanFutures.toArray(new CompletableFuture[0])); + + // Step 4: Gather successful loans + return allDone.thenApply(v -> { + List openLoans = loanFutures.stream() + .map(CompletableFuture::join) + .flatMap(Optional::stream) + .collect(Collectors.toList()); + + return Result.succeeded(openLoans); + }); + }); + } + + private CompletableFuture>> fetchItemsForTitleLevel(Request request) { + return itemByInstanceIdFinder.getItemsByInstanceId(UUID.fromString(request.getInstanceId()), false); + } + private CompletableFuture> sendLoanNotice(Loan updatedLoan, NoticeEventType eventType){ return getRecipientId(updatedLoan) From 25485a9b8921cf31ba18869959a2e5dc4bd4a653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20L=C3=A4mmer?= Date: Wed, 7 Jan 2026 14:58:21 +0100 Subject: [PATCH 4/4] CIRC-2470 Extract HoldNotices into separate sender - Extract HoldNotices in separate sender - Fix title level notices not sending by reusing item-level patron notice template for title level notices - Publish LOG_RECORD event when hold notice is placed and sent - Add tests --- .../domain/CreateRequestService.java | 15 +- .../resources/HoldNoticeSender.java | 398 +++++++++++++ .../RequestByInstanceIdResource.java | 3 +- .../resources/RequestCollectionResource.java | 10 +- .../resources/RequestNoticeSender.java | 144 +---- .../circulation/services/EventPublisher.java | 20 + .../api/TenantActivationResourceTests.java | 2 + .../java/api/loans/CheckInByBarcodeTests.java | 2 +- .../requests/RequestsAPICreationTests.java | 51 ++ .../java/api/support/fakes/FakePubSub.java | 7 + .../resources/HoldNoticeSenderTest.java | 562 ++++++++++++++++++ 11 files changed, 1075 insertions(+), 139 deletions(-) create mode 100644 src/main/java/org/folio/circulation/resources/HoldNoticeSender.java create mode 100644 src/test/java/org/folio/circulation/resources/HoldNoticeSenderTest.java diff --git a/src/main/java/org/folio/circulation/domain/CreateRequestService.java b/src/main/java/org/folio/circulation/domain/CreateRequestService.java index b010e06e07..825fd8354f 100644 --- a/src/main/java/org/folio/circulation/domain/CreateRequestService.java +++ b/src/main/java/org/folio/circulation/domain/CreateRequestService.java @@ -42,6 +42,7 @@ import org.apache.logging.log4j.Logger; import org.folio.circulation.domain.representations.logs.LogEventType; import org.folio.circulation.domain.validation.RequestLoanValidator; +import org.folio.circulation.resources.HoldNoticeSender; import org.folio.circulation.resources.RequestBlockValidators; import org.folio.circulation.resources.RequestNoticeSender; import org.folio.circulation.resources.handlers.error.CirculationErrorHandler; @@ -59,19 +60,22 @@ public class CreateRequestService { private final UpdateUponRequest updateUponRequest; private final RequestLoanValidator requestLoanValidator; private final RequestNoticeSender requestNoticeSender; + private final HoldNoticeSender holdNoticeSender; private final RequestBlockValidators requestBlockValidators; private final EventPublisher eventPublisher; private final CirculationErrorHandler errorHandler; public CreateRequestService(RequestRelatedRepositories repositories, UpdateUponRequest updateUponRequest, RequestLoanValidator requestLoanValidator, - RequestNoticeSender requestNoticeSender, RequestBlockValidators requestBlockValidators, - EventPublisher eventPublisher, CirculationErrorHandler errorHandler) { + RequestNoticeSender requestNoticeSender, HoldNoticeSender holdNoticeSender, + RequestBlockValidators requestBlockValidators, EventPublisher eventPublisher, + CirculationErrorHandler errorHandler) { this.repositories = repositories; this.updateUponRequest = updateUponRequest; this.requestLoanValidator = requestLoanValidator; this.requestNoticeSender = requestNoticeSender; + this.holdNoticeSender = holdNoticeSender; this.requestBlockValidators = requestBlockValidators; this.eventPublisher = eventPublisher; this.errorHandler = errorHandler; @@ -113,7 +117,9 @@ public CompletableFuture> createRequest( .thenApplyAsync(r -> { r.after(t -> eventPublisher.publishLogRecord(mapToRequestLogEventJson(t.getRequest()), getLogEventType())); return r.next(requestNoticeSender::sendNoticeOnRequestCreated); - }).thenApply(r -> logResult(r, "createRequest")); + }) + .thenComposeAsync(r -> r.after(this::sendHoldNoticeIfNeeded)) + .thenApply(r -> logResult(r, "createRequest")); } private Result refuseHoldOrRecallTlrWhenPageableItemExists( @@ -295,4 +301,7 @@ private LogEventType getLogEventType() { : REQUEST_CREATED; } + private CompletableFuture> sendHoldNoticeIfNeeded(RequestAndRelatedRecords records) { + return holdNoticeSender.sendHoldNoticeIfNeeded(records.getRequest()).thenApply(v -> Result.succeeded(records)); + } } diff --git a/src/main/java/org/folio/circulation/resources/HoldNoticeSender.java b/src/main/java/org/folio/circulation/resources/HoldNoticeSender.java new file mode 100644 index 0000000000..aee3c98640 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/HoldNoticeSender.java @@ -0,0 +1,398 @@ +package org.folio.circulation.resources; + +import static org.folio.circulation.domain.notice.NoticeEventType.HOLD_REQUEST_FOR_ITEM; +import static org.folio.circulation.domain.notice.TemplateContextUtil.createLoanNoticeContext; +import static org.folio.circulation.support.results.Result.emptyAsync; +import static org.folio.circulation.support.results.Result.succeeded; + +import java.lang.invoke.MethodHandles; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.circulation.domain.Item; +import org.folio.circulation.domain.Loan; +import org.folio.circulation.domain.Request; +import org.folio.circulation.domain.notice.ImmediatePatronNoticeService; +import org.folio.circulation.domain.notice.PatronNoticeEventBuilder; +import org.folio.circulation.domain.notice.SingleImmediatePatronNoticeService; +import org.folio.circulation.domain.representations.logs.NoticeLogContext; +import org.folio.circulation.domain.validation.ProxyRelationshipValidator; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.requests.RequestRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.storage.ItemByInstanceIdFinder; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.results.Result; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class HoldNoticeSender { + private static final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass()); + + private final ImmediatePatronNoticeService patronNoticeService; + private final LoanRepository loanRepository; + private final UserRepository userRepository; + private final RequestRepository requestRepository; + private final EventPublisher eventPublisher; + private final ProxyRelationshipValidator proxyRelationshipValidator; + private final ItemByInstanceIdFinder itemByInstanceIdFinder; + + public static HoldNoticeSender using(Clients clients) { + final var itemRepository = new ItemRepository(clients); + final var userRepository = new UserRepository(clients); + final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); + final var requestRepository = RequestRepository.using(clients, itemRepository, + userRepository, loanRepository); + + return new HoldNoticeSender( + new SingleImmediatePatronNoticeService(clients), + loanRepository, + userRepository, + requestRepository, + new EventPublisher(clients), + new ProxyRelationshipValidator(clients), + new ItemByInstanceIdFinder(clients.holdingsStorage(), itemRepository) + ); + } + + public CompletableFuture> sendHoldNoticeIfNeeded(Request request) { + if (request == null) { + log.warn("sendHoldNoticeIfNeeded:: request is null, skipping"); + return emptyAsync(); + } + + log.debug("sendHoldNoticeIfNeeded:: processing hold request {}", request.getId()); + + if (!request.isHold()) { + log.debug("sendHoldNoticeIfNeeded:: not a hold request, skipping"); + return emptyAsync(); + } + + if (request.isTitleLevel()) { + return sendHoldNoticesForTitleLevelRequest(request); + } else if (request.hasItemId()) { + return sendHoldNoticeForItemLevelRequest(request); + } + + log.debug("sendHoldNoticeIfNeeded:: request has no item ID and is not title-level"); + return emptyAsync(); + } + + private CompletableFuture> sendHoldNoticeForItemLevelRequest(Request request) { + return fetchLoanForRequest(request) + .thenCompose(r -> r.after(this::sendHoldNoticeToCurrentBorrower)); + } + + private CompletableFuture> sendHoldNoticesForTitleLevelRequest(Request request) { + log.info("sendHoldNoticesForTitleLevelRequest:: sending hold notices for title-level request: {}", + request.getId()); + + return collectOpenLoansForTitle(request.getInstanceId()) + .thenCompose(loansResult -> loansResult.after(loans -> + filterLoansWithoutExistingHolds(loans, request) + .thenCompose(filteredResult -> filteredResult.after(this::sendHoldNoticesToAllBorrowers)) + )); + } + + // ============================================================================ + // ITEM-LEVEL HOLD NOTICES + // ============================================================================ + + private CompletableFuture> fetchLoanForRequest(Request request) { + if (request.hasLoan()) { + return loanRepository.fetchLatestPatronInfoAddedComment(request.getLoan()) + .thenApply(r -> r.map(request::withLoan)); + } + return CompletableFuture.completedFuture(succeeded(request)); + } + + private CompletableFuture> sendHoldNoticeToCurrentBorrower(Request request) { + Loan loan = request.getLoan(); + + if (!shouldSendHoldNoticeForLoan(loan)) { + return emptyAsync(); + } + + return isFirstHoldRequestForItem(loan.getItemId(), request) + .thenCompose(result -> { + if (result.failed()) { + log.warn("sendHoldNoticeToCurrentBorrower:: query failed, skipping notice for safety"); + return emptyAsync(); + } + + boolean isFirst = result.value(); + if (Boolean.FALSE.equals(isFirst)) { + log.debug("sendHoldNoticeToCurrentBorrower:: not first hold request, skipping notice"); + return emptyAsync(); + } + return sendNoticeAndPublishEvent(loan); + }); + } + + private boolean shouldSendHoldNoticeForLoan(Loan loan) { + if (loan == null || !loan.isOpen()) { + log.debug("shouldSendHoldNoticeForLoan:: no open loan found"); + return false; + } + + if (loan.getUser() == null || loan.getItem() == null) { + log.debug("shouldSendHoldNoticeForLoan:: loan missing user or item data"); + return false; + } + + return true; + } + + private CompletableFuture> isFirstHoldRequestForItem(String itemId, Request currentRequest) { + return requestRepository.findOpenRequestsByItemIds(Collections.singletonList(itemId)) + .thenApply(result -> { + if (result.failed()) { + log.warn("isFirstHoldRequestForItem:: failed to query requests for item {}", itemId); + return Result.failed(result.cause()); + } + + long holdCount = result.value().getRecords().stream() + .filter(Request::isHold) + .filter(r -> !r.getId().equals(currentRequest.getId())) // Exclude current request + .count(); + + boolean isFirst = holdCount == 0; // Zero other holds means this is the first + log.debug("isFirstHoldRequestForItem:: found {} other hold requests, isFirst: {}", holdCount, isFirst); + return Result.succeeded(isFirst); + }); + } + + // ============================================================================ + // TITLE-LEVEL HOLD NOTICES + // ============================================================================ + + private CompletableFuture>> collectOpenLoansForTitle(String instanceId) { + log.debug("collectOpenLoansForTitle:: collecting open loans for instance {}", instanceId); + + return fetchItemsForInstance(instanceId) + .thenCompose(this::fetchOpenLoansForItems) + .thenCompose(this::enrichLoansWithUserData) + .thenApply(Result::succeeded); + } + + private CompletableFuture> fetchItemsForInstance(String instanceId) { + if (instanceId == null) { + log.warn("fetchItemsForInstance:: instanceId is null"); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + try { + UUID instanceUUID = UUID.fromString(instanceId); + return itemByInstanceIdFinder.getItemsByInstanceId(instanceUUID, false) + .thenApply(itemsResult -> { + if (itemsResult.failed()) { + log.warn("fetchItemsForInstance:: failed to fetch items: {}", itemsResult.cause()); + return Collections.emptyList(); + } + + var items = itemsResult.value(); + if (items == null || items.isEmpty()) { + log.info("fetchItemsForInstance:: no items found for instance"); + return Collections.emptyList(); + } + + log.debug("fetchItemsForInstance:: found {} items", items.size()); + return new ArrayList<>(items); + }); + } catch (IllegalArgumentException e) { + log.error("fetchItemsForInstance:: invalid instanceId format: {}", instanceId, e); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + } + + private CompletableFuture> fetchOpenLoansForItems(List items) { + if (items == null || items.isEmpty()) { + log.debug("fetchOpenLoansForItems:: no items to fetch loans for"); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + List> loanFutures = items.stream() + .map(this::fetchOpenLoanForItem) + .toList(); + + return CompletableFuture.allOf(loanFutures.toArray(new CompletableFuture[0])) + .thenApply(v -> loanFutures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList()); + } + + private CompletableFuture fetchOpenLoanForItem(Item item) { + return loanRepository.findOpenLoanForItem(item) + .thenApply(result -> { + if (result.failed() || result.value() == null) { + log.debug("fetchOpenLoanForItem:: no open loan for item {}", item.getItemId()); + return null; + } + return result.value(); + }); + } + + private CompletableFuture> enrichLoansWithUserData(List loans) { + if (loans == null || loans.isEmpty()) { + log.debug("enrichLoansWithUserData:: no loans to enrich"); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + return userRepository.findUsersForLoans(loans) + .thenApply(result -> { + if (result.failed()) { + log.warn("enrichLoansWithUserData:: failed to fetch users: {}", result.cause()); + return Collections.emptyList(); + } + + List validLoans = result.value().stream() + .filter(loan -> loan.getUser() != null) + .toList(); + + if (validLoans.size() < loans.size()) { + log.warn("enrichLoansWithUserData:: {} of {} loans could not be enriched with user data", + loans.size() - validLoans.size(), loans.size()); + } + + log.debug("enrichLoansWithUserData:: enriched {} loans with user data", validLoans.size()); + return validLoans; + }); + } + + private CompletableFuture>> filterLoansWithoutExistingHolds( + List loans, Request currentRequest) { + + if (loans == null || loans.isEmpty()) { + log.debug("filterLoansWithoutExistingHolds:: no loans to filter"); + return CompletableFuture.completedFuture(Result.succeeded(Collections.emptyList())); + } + + log.debug("filterLoansWithoutExistingHolds:: filtering {} loans", loans.size()); + + List> filteredLoanFutures = loans.stream() + .map(loan -> isFirstHoldRequestForItem(loan.getItemId(), currentRequest) + .thenApply(result -> { + if (result.failed()) { + log.warn("filterLoansWithoutExistingHolds:: failed to check holds for item {}, excluding from notifications", + loan.getItemId()); + return null; + } + + boolean isFirst = result.value(); + if (isFirst) { + log.debug("filterLoansWithoutExistingHolds:: item {} has no holds, will notify borrower", + loan.getItemId()); + return loan; + } else { + log.debug("filterLoansWithoutExistingHolds:: item {} already has holds, skipping notification", + loan.getItemId()); + return null; + } + })) + .toList(); + + return CompletableFuture.allOf(filteredLoanFutures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List filteredLoans = filteredLoanFutures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + + log.info("filterLoansWithoutExistingHolds:: filtered {} loans to {} without existing holds", + loans.size(), filteredLoans.size()); + + return Result.succeeded(filteredLoans); + }); + } + + private CompletableFuture> sendHoldNoticesToAllBorrowers(List loans) { + if (loans.isEmpty()) { + log.info("sendHoldNoticesToAllBorrowers:: no current borrowers to notify"); + return emptyAsync(); + } + + log.info("sendHoldNoticesToAllBorrowers:: sending notices to {} borrowers", loans.size()); + + List>> noticeAndEventFutures = loans.stream() + .map(this::sendNoticeAndPublishEvent) + .toList(); + + return allOfResults(noticeAndEventFutures); + } + + // ============================================================================ + // NOTICE SENDING + // ============================================================================ + + private CompletableFuture> sendNoticeAndPublishEvent(Loan loan) { + return sendLoanNotice(loan) + .thenCompose(r -> r.after(v -> eventPublisher.publishHoldRequestedEvent(loan))); + } + + private CompletableFuture> sendLoanNotice(Loan loan) { + return getRecipientId(loan) + .thenCompose(result -> result.after(recipientId -> { + var patronNoticeEvent = new PatronNoticeEventBuilder() + .withItem(loan.getItem()) + .withUser(loan.getUser()) + .withRecipientId(recipientId) + .withEventType(HOLD_REQUEST_FOR_ITEM) + .withNoticeContext(createLoanNoticeContext(loan)) + .withNoticeLogContext(NoticeLogContext.from(loan)) + .build(); + + return patronNoticeService.acceptNoticeEvent(patronNoticeEvent); + })); + } + + private CompletableFuture> getRecipientId(Loan loan) { + return proxyRelationshipValidator.hasActiveProxyRelationshipWithNotificationsSentToProxy(loan) + .thenApply(result -> result.map(sentToProxy -> { + if (Boolean.TRUE.equals(sentToProxy)) { + log.info("getRecipientId:: notice recipient is proxy user: {}", loan.getProxyUserId()); + return loan.getProxyUserId(); + } + + log.info("getRecipientId:: notice recipient is user: {}", loan.getUserId()); + return loan.getUserId(); + })); + } + + // ============================================================================ + // UTILITY METHODS + // ============================================================================ + + private CompletableFuture> allOfResults(List>> futures) { + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List> results = futures.stream() + .map(CompletableFuture::join) + .toList(); + + List> failures = results.stream() + .filter(Result::failed) + .toList(); + + if (failures.isEmpty()) { + return Result.succeeded(null); + } + + log.error("allOfResults:: {} of {} notice sending operations failed", + failures.size(), results.size()); + failures.forEach(failure -> + log.error("allOfResults:: failure: {}", failure.cause())); + + return failures.get(0); + }); + } +} diff --git a/src/main/java/org/folio/circulation/resources/RequestByInstanceIdResource.java b/src/main/java/org/folio/circulation/resources/RequestByInstanceIdResource.java index 86fabe4f4c..b73432db31 100644 --- a/src/main/java/org/folio/circulation/resources/RequestByInstanceIdResource.java +++ b/src/main/java/org/folio/circulation/resources/RequestByInstanceIdResource.java @@ -297,7 +297,8 @@ private CompletableFuture> placeRequests( final CreateRequestService createRequestService = new CreateRequestService(repositories, updateUponRequest, new RequestLoanValidator(itemFinder, loanRepository), - new RequestNoticeSender(clients), regularRequestBlockValidators(clients), eventPublisher, + new RequestNoticeSender(clients), HoldNoticeSender.using(clients), + regularRequestBlockValidators(clients), eventPublisher, new FailFastErrorHandler()); return placeRequest(requestRepresentations, 0, createRequestService, diff --git a/src/main/java/org/folio/circulation/resources/RequestCollectionResource.java b/src/main/java/org/folio/circulation/resources/RequestCollectionResource.java index 63104980a7..6359d85a70 100644 --- a/src/main/java/org/folio/circulation/resources/RequestCollectionResource.java +++ b/src/main/java/org/folio/circulation/resources/RequestCollectionResource.java @@ -90,6 +90,7 @@ void create(RoutingContext routingContext) { final var requestRepository = repositories.getRequestRepository(); final var requestNoticeSender = new RequestNoticeSender(clients); + final var holdNoticeSender = HoldNoticeSender.using(clients); final var updateUponRequest = new UpdateUponRequest(new UpdateItem(itemRepository, new RequestQueueService(new RequestPolicyRepository(clients), loanPolicyRepository)), new UpdateLoan(clients, loanRepository, loanPolicyRepository), @@ -106,8 +107,8 @@ void create(RoutingContext routingContext) { clients.holdingsStorage(), itemRepository), loanRepository); final var createRequestService = new CreateRequestService(repositories, updateUponRequest, - requestLoanValidator, requestNoticeSender, requestBlocksValidators, eventPublisher, - errorHandler); + requestLoanValidator, requestNoticeSender, holdNoticeSender, requestBlocksValidators, + eventPublisher, errorHandler); final var requestFromRepresentationService = new RequestFromRepresentationService( Request.Operation.CREATE, repositories, @@ -157,10 +158,11 @@ void replace(RoutingContext routingContext) { final var errorHandler = new FailFastErrorHandler(); final var requestLoanValidator = new RequestLoanValidator( new ItemByInstanceIdFinder(clients.holdingsStorage(), itemRepository), loanRepository); + final var holdNoticeSender = HoldNoticeSender.using(clients); final var createRequestService = new CreateRequestService(repositories, updateUponRequest, - requestLoanValidator, requestNoticeSender, regularRequestBlockValidators(clients), - eventPublisher, errorHandler); + requestLoanValidator, requestNoticeSender, holdNoticeSender, + regularRequestBlockValidators(clients), eventPublisher, errorHandler); final var updateRequestService = new UpdateRequestService(requestRepository, updateRequestQueue, new ClosedRequestValidator(requestRepository), requestNoticeSender, diff --git a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java index 5d4733d740..1b6f34a20a 100644 --- a/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java +++ b/src/main/java/org/folio/circulation/resources/RequestNoticeSender.java @@ -1,6 +1,8 @@ package org.folio.circulation.resources; -import static org.folio.circulation.domain.notice.NoticeEventType.*; +import static org.folio.circulation.domain.notice.NoticeEventType.AVAILABLE; +import static org.folio.circulation.domain.notice.NoticeEventType.ITEM_RECALLED; +import static org.folio.circulation.domain.notice.NoticeEventType.REQUEST_CANCELLATION; import static org.folio.circulation.domain.notice.PatronNotice.buildEmail; import static org.folio.circulation.domain.notice.TemplateContextUtil.createLoanNoticeContext; import static org.folio.circulation.domain.notice.TemplateContextUtil.createRequestNoticeContext; @@ -11,10 +13,12 @@ import static org.folio.circulation.support.results.Result.succeeded; import java.lang.invoke.MethodHandles; -import java.util.*; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.function.Function; -import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -44,7 +48,6 @@ import org.folio.circulation.infrastructure.storage.requests.RequestRepository; import org.folio.circulation.infrastructure.storage.users.UserRepository; import org.folio.circulation.services.EventPublisher; -import org.folio.circulation.storage.ItemByInstanceIdFinder; import org.folio.circulation.support.Clients; import org.folio.circulation.support.HttpFailure; import org.folio.circulation.support.RecordNotFoundFailure; @@ -63,10 +66,8 @@ public class RequestNoticeSender { private final ServicePointRepository servicePointRepository; private final EventPublisher eventPublisher; private final ProxyRelationshipValidator proxyRelationshipValidator; - private final ItemByInstanceIdFinder itemByInstanceIdFinder; private long recallRequestCount = 0L; - private long holdRequestCount = 0L; protected final ImmediatePatronNoticeService patronNoticeService; protected final LocationRepository locationRepository; @@ -90,7 +91,6 @@ public RequestNoticeSender(Clients clients) { this.eventPublisher = new EventPublisher(clients); this.locationRepository = LocationRepository.using(clients, servicePointRepository); this.proxyRelationshipValidator = new ProxyRelationshipValidator(clients); - this.itemByInstanceIdFinder = new ItemByInstanceIdFinder(clients.holdingsStorage(), itemRepository); } public static RequestNoticeSender using(Clients clients) { @@ -117,16 +117,8 @@ public Result sendNoticeOnRequestCreated( Request request = records.getRequest(); recallRequestCount = records.getRequestQueue().getRequests() .stream() - .filter(r -> r.getRequestType() == RequestType.RECALL - && r.isNotYetFilled() - && Objects.equals(r.getItemId(), request.getItemId())) - .count(); - - holdRequestCount = records.getRequestQueue().getRequests() - .stream() - .filter(r -> r.getRequestType() == RequestType.HOLD - && r.isNotYetFilled() - && Objects.equals(r.getItemId(), request.getItemId())) + .filter(r -> r.getRequestType() == RequestType.RECALL && r.isNotYetFilled() + && r.getItemId().equals(request.getItemId())) .count(); if (request.hasItemId()) { @@ -235,10 +227,7 @@ private CompletableFuture> sendConfirmationNoticeForRequestWithItem log.debug("sendConfirmationNoticeForRequestWithItem:: parameters request: {}", () -> request); return createPatronNoticeEvent(request, getEventType(request)) .thenCompose(r -> r.after(patronNoticeService::acceptNoticeEvent)) - .whenComplete((r, t) -> { - sendNoticeOnRecall(request); - sendHoldNoticeForItemLevelRequest(request); - }); + .whenComplete((r, t) -> sendNoticeOnRecall(request)); } private CompletableFuture> sendConfirmationNoticeForRequestWithoutItemId( @@ -289,16 +278,7 @@ private CompletableFuture> fetchDataAndSendNotice(Request request, () -> request, () -> templateId, () -> eventType); return fetchAdditionalInfo(request) - .thenCompose(r -> r.after(req -> { - CompletableFuture> noticeFuture = sendNotice(req, templateId, eventType); - if (req.isHold()) { - CompletableFuture> titleLevelFuture = sendNoticeForTitleLevelRequest(req); - return CompletableFuture.allOf(noticeFuture, titleLevelFuture) - .thenApply(v -> Result.succeeded(null)); - } else { - return noticeFuture; - } - })); + .thenCompose(r -> r.after(req -> sendNotice(req, templateId, eventType))); } private CompletableFuture> sendNotice(Request request, UUID templateId, @@ -365,113 +345,17 @@ private CompletableFuture> sendNoticeOnRecall(Request request) { } return fetchAdditionalInfo(request) - .thenCompose(r -> r.after(req -> sendLoanNotice(req.getLoan(), ITEM_RECALLED))); + .thenCompose(r -> r.after(req -> sendLoanNotice(req.getLoan()))); } - /** - * Sends hold notice to the borrower of a specific item - */ - private CompletableFuture> sendHoldNoticeForItemLevelRequest(Request request) { - log.debug("sendHoldNoticeForItemLevelRequest:: sending notice for item: {}", - request.getItemId()); - - return fetchAdditionalInfo(request) - .thenCompose(r -> r.after(req -> { - Loan loan = req.getLoan(); - - if (!request.isHold() || loan == null || loan.getUser() == null || loan.getItem() == null || holdRequestCount > 1) { - log.debug("sendHoldNoticeForItemLevelRequest:: no active loan found, skipping notice"); - return ofAsync(null); - } - - if (!loan.isOpen()) { - log.debug("sendHoldNoticeForItemLevelRequest:: loan is not open, skipping notice"); - return ofAsync(null); - } - - return sendLoanNotice(loan, HOLD_REQUEST_FOR_ITEM); - })); - } - - private CompletableFuture> sendNoticeForTitleLevelRequest(Request request) { - log.debug("sendNoticeForTitleLevelRequest:: sending notice for item: {}", request.getItemId()); - - return collectOpenLoansForTitleLevel(request) - .thenCompose(loansResult -> { - if (loansResult.failed() || loansResult.value() == null) { - return ofAsync(null); - } - - Collection openLoans = loansResult.value(); - if (openLoans.isEmpty()) { - return ofAsync(null); - } - - List>> noticeFutures = openLoans.stream() - .map(loan -> sendLoanNotice(loan, HOLD_REQUEST_FOR_ITEM)) - .toList(); - - CompletableFuture allDone = - CompletableFuture.allOf(noticeFutures.toArray(new CompletableFuture[0])); - - return allDone.thenApply(r -> Result.succeeded(null)); - }); - } - - - public CompletableFuture>> collectOpenLoansForTitleLevel(Request request) { - - // Step 1: Fetch all items for this title level - return fetchItemsForTitleLevel(request) - .thenCompose(itemsResult -> { - if (itemsResult.failed() || itemsResult.value() == null) { - return CompletableFuture.completedFuture(Result.failed(itemsResult.cause())); - } - - Collection items = itemsResult.value(); - - // Step 2: For each item, find open loan asynchronously - List>> loanFutures = items.stream() - .map(item -> loanRepository.findOpenLoanForItem(item) - .thenApply(loanResult -> { - if (loanResult.succeeded() && loanResult.value() != null) { - return Optional.of(loanResult.value()); - } else { - return Optional.empty(); - } - }) - ) - .toList(); - - // Step 3: Combine all futures - CompletableFuture allDone = - CompletableFuture.allOf(loanFutures.toArray(new CompletableFuture[0])); - - // Step 4: Gather successful loans - return allDone.thenApply(v -> { - List openLoans = loanFutures.stream() - .map(CompletableFuture::join) - .flatMap(Optional::stream) - .collect(Collectors.toList()); - - return Result.succeeded(openLoans); - }); - }); - } - - private CompletableFuture>> fetchItemsForTitleLevel(Request request) { - return itemByInstanceIdFinder.getItemsByInstanceId(UUID.fromString(request.getInstanceId()), false); - } - - - private CompletableFuture> sendLoanNotice(Loan updatedLoan, NoticeEventType eventType){ + private CompletableFuture> sendLoanNotice(Loan updatedLoan){ return getRecipientId(updatedLoan) .thenCompose(result -> result.after(recipientId -> { var itemRecalledEvent = new PatronNoticeEventBuilder() .withItem(updatedLoan.getItem()) .withUser(updatedLoan.getUser()) .withRecipientId(recipientId) - .withEventType(eventType) + .withEventType(ITEM_RECALLED) .withNoticeContext(createLoanNoticeContext(updatedLoan)) .withNoticeLogContext(NoticeLogContext.from(updatedLoan)) .build(); diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java index 551a01a677..218685e494 100644 --- a/src/main/java/org/folio/circulation/services/EventPublisher.java +++ b/src/main/java/org/folio/circulation/services/EventPublisher.java @@ -278,6 +278,26 @@ public CompletableFuture> publishRecallRequestedEvent(Loan loan) { .thenCompose(loanLogContext -> loanLogContext.after(ctx -> publishLogRecord(ctx, LOAN))); } + public CompletableFuture> publishHoldRequestedEvent(Loan loan) { + logger.debug("publishHoldRequestedEvent:: publishing hold requested event for loan {}", + loan != null ? loan.getId() : "null"); + + if (loan == null || loan.getId() == null) { + logger.error("publishHoldRequestedEvent:: loan or loan ID is null, cannot publish event"); + return emptyAsync(); + } + + return getTenantTimeZone() + .thenApply(zoneResult -> zoneResult.map(zoneId -> { + var logDescription = format("Hold request placed on item (loan %s)", loan.getId()); + return LoanLogContext.from(loan) + .withAction("Hold request") + .withDescription(logDescription) + .asJson(); + })) + .thenCompose(loanLogContext -> loanLogContext.after(ctx -> publishLogRecord(ctx, LOAN))); + } + public CompletableFuture> publishDueDateLogEvent(Loan loan) { return getTenantTimeZone() .thenApply(zoneResult -> zoneResult.map(zoneId -> { diff --git a/src/test/java/api/TenantActivationResourceTests.java b/src/test/java/api/TenantActivationResourceTests.java index 2f46522ab6..1b1d8def67 100644 --- a/src/test/java/api/TenantActivationResourceTests.java +++ b/src/test/java/api/TenantActivationResourceTests.java @@ -1,6 +1,7 @@ package api; import static api.support.APITestContext.TENANT_ID; +import static api.support.fakes.FakePubSub.clearRegistrationData; import static api.support.fakes.FakePubSub.getCreatedEventTypes; import static api.support.fakes.FakePubSub.getDeletedEventTypes; import static api.support.fakes.FakePubSub.getRegisteredPublishers; @@ -41,6 +42,7 @@ class TenantActivationResourceTests extends APITests { @BeforeEach public void init() { + clearRegistrationData(); setFailPubSubRegistration(false); setFailPubSubUnregistering(false); } diff --git a/src/test/java/api/loans/CheckInByBarcodeTests.java b/src/test/java/api/loans/CheckInByBarcodeTests.java index d5d82f2f9e..cd4e9bf601 100644 --- a/src/test/java/api/loans/CheckInByBarcodeTests.java +++ b/src/test/java/api/loans/CheckInByBarcodeTests.java @@ -1900,7 +1900,7 @@ void shouldNotLinkTitleLevelHoldRequestToAnItemUponCheckInWhenItemIsNonRequestab assertThat(requestAfterCheckIn, RequestMatchers.isOpenNotYetFilled()); final var publishedEvents = waitAtMost(2, SECONDS) - .until(FakePubSub::getPublishedEvents, hasSize(5)); + .until(FakePubSub::getPublishedEvents, hasSize(6)); final var checkedInEvent = publishedEvents.findFirst(byEventType(ITEM_CHECKED_IN.name())); assertThat(checkedInEvent, isValidItemCheckedInEvent(checkInResponse.getLoan())); final var checkInLogEvent = publishedEvents.findFirst(byLogEventType(CHECK_IN.value())); diff --git a/src/test/java/api/requests/RequestsAPICreationTests.java b/src/test/java/api/requests/RequestsAPICreationTests.java index 86060213d8..dad3535f49 100644 --- a/src/test/java/api/requests/RequestsAPICreationTests.java +++ b/src/test/java/api/requests/RequestsAPICreationTests.java @@ -175,6 +175,7 @@ public class RequestsAPICreationTests extends APITests { private static final String HOLD_REQUEST_EVENT = "Hold request"; private static final String RECALL_REQUEST_EVENT = "Recall request"; private static final String ITEM_RECALLED = "Item recalled"; + private static final String HOLD_REQUEST_FOR_ITEM = "Hold request for item"; private static final UUID CONFIRMATION_TEMPLATE_ID_FROM_NOTICE_POLICY = UUID.randomUUID(); private static final UUID CANCELLATION_TEMPLATE_ID_FROM_NOTICE_POLICY = UUID.randomUUID(); @@ -2093,6 +2094,56 @@ void canCreateHoldRequestWhenItemIsAwaitingPickup() { assertThat(holdRequest.getJson().getString("status"), is(RequestStatus.OPEN_NOT_YET_FILLED.getValue())); } + @Test + void titleLevelHoldRequestShouldSendNoticesToAllBorrowers() { + // Enable title-level requests + settingsFixture.enableTlrFeature(); + + // Configure patron notice policy with "Hold request for item" loan notice + UUID holdRequestForItemTemplateId = UUID.randomUUID(); + JsonObject holdRequestForItemNoticeConfiguration = new NoticeConfigurationBuilder() + .withTemplateId(holdRequestForItemTemplateId) + .withEventType(HOLD_REQUEST_FOR_ITEM) + .create(); + NoticePolicyBuilder noticePolicy = new NoticePolicyBuilder() + .withName("Policy with hold request for item notice") + .withLoanNotices(Collections.singletonList(holdRequestForItemNoticeConfiguration)); + useFallbackPolicies( + loanPoliciesFixture.canCirculateRolling().getId(), + requestPoliciesFixture.allowAllRequestPolicy().getId(), + noticePoliciesFixture.create(noticePolicy).getId(), + overdueFinePoliciesFixture.facultyStandard().getId(), + lostItemFeePoliciesFixture.facultyStandard().getId()); + + // Create 3 items for the same instance + List items = itemsFixture.createMultipleItemsForTheSameInstance(3); + UUID instanceId = items.get(0).getInstanceId(); + + // Check out all 3 items to different borrowers + final IndividualResource borrower1 = usersFixture.jessica(); + final IndividualResource borrower2 = usersFixture.steve(); + final IndividualResource borrower3 = usersFixture.charlotte(); + + checkOutFixture.checkOutByBarcode(items.get(0), borrower1); + checkOutFixture.checkOutByBarcode(items.get(1), borrower2); + checkOutFixture.checkOutByBarcode(items.get(2), borrower3); + + // Create a title-level hold request from a different user + final IndividualResource requester = usersFixture.james(); + final IndividualResource titleLevelHoldRequest = requestsFixture.placeTitleLevelHoldShelfRequest( + instanceId, requester); + + // Verify the request was created correctly + assertThat(titleLevelHoldRequest.getJson().getString("requestType"), is(HOLD.getValue())); + assertThat(titleLevelHoldRequest.getJson().getString("requestLevel"), is("Title")); + assertThat(titleLevelHoldRequest.getJson().getString("status"), is(RequestStatus.OPEN_NOT_YET_FILLED.getValue())); + + // Wait for events to be published: 3 checkouts + 3 LOG_RECORD (hold) + 3 LOG_RECORD (notice) + others + Awaitility.await() + .atMost(2, TimeUnit.SECONDS) + .until(FakePubSub::getPublishedEvents, hasSize(13)); + } + @Test void canCreateHoldRequestWhenItemIsInTransit() { final IndividualResource requestPickupServicePoint = servicePointsFixture.cd1(); diff --git a/src/test/java/api/support/fakes/FakePubSub.java b/src/test/java/api/support/fakes/FakePubSub.java index ac6fdec5d7..e7a80fc467 100644 --- a/src/test/java/api/support/fakes/FakePubSub.java +++ b/src/test/java/api/support/fakes/FakePubSub.java @@ -139,6 +139,13 @@ public static void clearPublishedEvents() { publishedEvents.clear(); } + public static void clearRegistrationData() { + createdEventTypes.clear(); + registeredPublishers.clear(); + registeredSubscribers.clear(); + deletedEventTypes.clear(); + } + public static void setFailPubSubRegistration(boolean failPubSubRegistration) { FakePubSub.failPubSubRegistration = failPubSubRegistration; } diff --git a/src/test/java/org/folio/circulation/resources/HoldNoticeSenderTest.java b/src/test/java/org/folio/circulation/resources/HoldNoticeSenderTest.java new file mode 100644 index 0000000000..fd4fc69eb6 --- /dev/null +++ b/src/test/java/org/folio/circulation/resources/HoldNoticeSenderTest.java @@ -0,0 +1,562 @@ +package org.folio.circulation.resources; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.folio.circulation.domain.Item; +import org.folio.circulation.domain.Loan; +import org.folio.circulation.domain.MultipleRecords; +import org.folio.circulation.domain.Request; +import org.folio.circulation.domain.User; +import org.folio.circulation.domain.configuration.TlrSettingsConfiguration; +import org.folio.circulation.domain.notice.ImmediatePatronNoticeService; +import org.folio.circulation.domain.validation.ProxyRelationshipValidator; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.requests.RequestRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.storage.ItemByInstanceIdFinder; +import org.folio.circulation.support.results.Result; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import api.support.builders.ItemBuilder; +import api.support.builders.LoanBuilder; +import api.support.builders.RequestBuilder; +import io.vertx.core.json.JsonObject; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HoldNoticeSender Tests") +class HoldNoticeSenderTest { + + @Mock + private ImmediatePatronNoticeService patronNoticeService; + @Mock + private LoanRepository loanRepository; + @Mock + private UserRepository userRepository; + @Mock + private RequestRepository requestRepository; + @Mock + private EventPublisher eventPublisher; + @Mock + private ProxyRelationshipValidator proxyRelationshipValidator; + @Mock + private ItemByInstanceIdFinder itemByInstanceIdFinder; + + @InjectMocks + private HoldNoticeSender holdNoticeSender; + + private Request holdRequest; + private Loan openLoan; + private Item item; + private User borrower; + private UUID itemId; + private UUID userId; + + @BeforeEach + void setUp() { + itemId = UUID.randomUUID(); + userId = UUID.randomUUID(); + + borrower = buildUser("John", "Doe"); + item = Item.from(new ItemBuilder().withId(itemId).create()); + + openLoan = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withUserId(userId) + .open() + .create()) + .withItem(item) + .withUser(borrower); + + holdRequest = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withRequesterId(UUID.randomUUID()) + .create()) + .withLoan(openLoan); + + + + } + + private static User buildUser(String firstName, String lastName) { + return User.from(new JsonObject() + .put("id", UUID.randomUUID().toString()) + .put("personal", new JsonObject() + .put("firstName", firstName) + .put("lastName", lastName))); + } + + // ============================================================================ + // ITEM-LEVEL HOLD NOTICE TESTS + // ============================================================================ + + @Test + @DisplayName("Should send notice for first hold request on checked-out item") + void shouldSendNoticeForFirstHoldRequestOnCheckedOutItem() throws Exception { + MultipleRecords singleHoldRequest = new MultipleRecords<>( + Collections.singletonList(holdRequest), 1); + when(requestRepository.findOpenRequestsByItemIds(Collections.singletonList(item.getItemId()))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(singleHoldRequest))); + when(loanRepository.fetchLatestPatronInfoAddedComment(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(openLoan))); + when(proxyRelationshipValidator + .hasActiveProxyRelationshipWithNotificationsSentToProxy(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(false))); + + // Stub event publisher and notice service for this test + when(eventPublisher.publishHoldRequestedEvent(any(Loan.class))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(null))); + + when(patronNoticeService.acceptNoticeEvent(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(null))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(holdRequest).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, times(1)).acceptNoticeEvent(any()); + verify(eventPublisher, times(1)).publishHoldRequestedEvent(openLoan); + } + + @Test + @DisplayName("Should not send notice for second hold request on same item") + void shouldNotSendNoticeForSecondHoldRequestOnSameItem() throws Exception { + Request secondHoldRequest = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withRequesterId(UUID.randomUUID()) + .create()); + MultipleRecords twoHoldRequests = new MultipleRecords<>( + Arrays.asList(holdRequest, secondHoldRequest), 2); + when(requestRepository.findOpenRequestsByItemIds(Collections.singletonList(item.getItemId()))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(twoHoldRequests))); + when(loanRepository.fetchLatestPatronInfoAddedComment(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(openLoan))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(holdRequest).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should not send notice when loan is not open") + void shouldNotSendNoticeWhenLoanIsNotOpen() throws Exception { + Loan closedLoan = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withUserId(userId) + .closed() + .create()) + .withItem(item) + .withUser(borrower); + + Request requestWithClosedLoan = holdRequest.withLoan(closedLoan); + when(loanRepository.fetchLatestPatronInfoAddedComment(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(closedLoan))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(requestWithClosedLoan).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should not send notice for non-hold request") + void shouldNotSendNoticeForNonHoldRequest() throws Exception { + Request recallRequest = Request.from(new RequestBuilder() + .recall() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withRequesterId(UUID.randomUUID()) + .create()) + .withLoan(openLoan); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(recallRequest).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + // ============================================================================ + // TITLE-LEVEL HOLD NOTICE TESTS + // ============================================================================ + + @Test + @DisplayName("Should send notices to all borrowers for title-level hold") + void shouldSendNoticesToAllBorrowersForTitleLevelHold() throws Exception { + UUID instanceId = UUID.randomUUID(); + User borrower1 = buildUser("Alice", "Smith"); + User borrower2 = buildUser("Bob", "Jones"); + UUID itemId1 = UUID.randomUUID(); + UUID itemId2 = UUID.randomUUID(); + + Item item1 = Item.from(new ItemBuilder().withId(itemId1).create()); + Item item2 = Item.from(new ItemBuilder().withId(itemId2).create()); + + Loan loan1 = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId1) + .withUserId(UUID.fromString(borrower1.getId())) + .open() + .create()) + .withItem(item1) + .withUser(borrower1); + + Loan loan2 = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId2) + .withUserId(UUID.fromString(borrower2.getId())) + .open() + .create()) + .withItem(item2) + .withUser(borrower2); + + Request titleLevelHoldRequest = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withInstanceId(instanceId) + .withRequestLevel("Title") + .withRequesterId(UUID.randomUUID()) + .create()) + .withTlrSettingsConfiguration( + TlrSettingsConfiguration.from(new JsonObject() + .put("titleLevelRequestsFeatureEnabled", true)) + ); + + when(itemByInstanceIdFinder.getItemsByInstanceId(instanceId, false)) + .thenReturn(CompletableFuture.completedFuture( + Result.succeeded(Arrays.asList(item1, item2)) + )); + + // Mock for first-hold check (no existing holds) + MultipleRecords noExistingHolds = new MultipleRecords<>( + Collections.singletonList(titleLevelHoldRequest), 1); + when(requestRepository.findOpenRequestsByItemIds(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(noExistingHolds))); + + // Mock for individual loan fetching (parallel queries) + when(loanRepository.findOpenLoanForItem(item1)) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(loan1))); + when(loanRepository.findOpenLoanForItem(item2)) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(loan2))); + + // Mock for batch user enrichment + Loan enrichedLoan1 = loan1.withUser(borrower1); + Loan enrichedLoan2 = loan2.withUser(borrower2); + Collection enrichedLoans = Arrays.asList(enrichedLoan1, enrichedLoan2); + when(userRepository.findUsersForLoans(any(Collection.class))) + .thenReturn(CompletableFuture.completedFuture( + Result.succeeded(enrichedLoans))); + when(proxyRelationshipValidator + .hasActiveProxyRelationshipWithNotificationsSentToProxy(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(false))); + + // Stub event publisher and notice service for this test + when(eventPublisher.publishHoldRequestedEvent(any(Loan.class))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(null))); + + when(patronNoticeService.acceptNoticeEvent(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(null))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(titleLevelHoldRequest).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, times(2)).acceptNoticeEvent(any()); + verify(eventPublisher, times(2)).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should handle empty loan list gracefully") + void shouldHandleEmptyLoanListGracefully() throws Exception { + UUID instanceId = UUID.randomUUID(); + Request titleLevelHoldRequest = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withInstanceId(instanceId) + .withRequestLevel("Title") + .withRequesterId(UUID.randomUUID()) + .create()) + .withTlrSettingsConfiguration( + TlrSettingsConfiguration.from(new JsonObject() + .put("titleLevelRequestsFeatureEnabled", true)) + ); + + when(itemByInstanceIdFinder.getItemsByInstanceId(instanceId, false)) + .thenReturn(CompletableFuture.completedFuture( + Result.succeeded(Collections.emptyList()) + )); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(titleLevelHoldRequest).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + // ============================================================================ + // ERROR HANDLING TESTS + // ============================================================================ + + @Test + @DisplayName("Should handle query failure in first hold check gracefully") + void shouldHandleQueryFailureGracefully() throws Exception { + when(requestRepository.findOpenRequestsByItemIds(Collections.singletonList(item.getItemId()))) + .thenReturn(CompletableFuture.completedFuture(Result.failed(null))); + when(loanRepository.fetchLatestPatronInfoAddedComment(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(openLoan))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(holdRequest).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should handle null loan gracefully") + void shouldHandleNullLoanGracefully() throws Exception { + Request requestWithNullLoan = holdRequest.withLoan(null); + // No need to mock fetchLatestPatronInfoAddedComment - it won't be called with null loan + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(requestWithNullLoan).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should handle loan with null item gracefully") + void shouldHandleLoanWithNullItemGracefully() throws Exception { + Loan loanWithNullItem = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withUserId(userId) + .open() + .create()) + .withUser(borrower); + // Item is null + + Request requestWithBadLoan = holdRequest.withLoan(loanWithNullItem); + when(loanRepository.fetchLatestPatronInfoAddedComment(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(loanWithNullItem))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(requestWithBadLoan).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should handle loan with null user gracefully") + void shouldHandleLoanWithNullUserGracefully() throws Exception { + Loan loanWithNullUser = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId) + .withUserId(userId) + .open() + .create()) + .withItem(item); + // User is null + + Request requestWithBadLoan = holdRequest.withLoan(loanWithNullUser); + when(loanRepository.fetchLatestPatronInfoAddedComment(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(loanWithNullUser))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(requestWithBadLoan).get(); + + assertTrue(result.succeeded()); + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Should not send notices for second title-level hold on same instance") + void shouldNotSendNoticesForSecondTitleLevelHold() throws Exception { + UUID instanceId = UUID.randomUUID(); + UUID itemId1 = UUID.randomUUID(); + Item item1 = Item.from(new ItemBuilder().withId(itemId1).create()); + + Request firstHoldRequest = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withItemId(itemId1) + .create()); + + Request secondTitleLevelHoldRequest = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withInstanceId(instanceId) + .withRequestLevel("Title") + .withRequesterId(UUID.randomUUID()) + .create()) + .withTlrSettingsConfiguration( + TlrSettingsConfiguration.from(new JsonObject() + .put("titleLevelRequestsFeatureEnabled", true)) + ); + + // Mock that items exist for the instance + when(itemByInstanceIdFinder.getItemsByInstanceId(instanceId, false)) + .thenReturn(CompletableFuture.completedFuture( + Result.succeeded(Collections.singletonList(item1)) + )); + + // Mock loan fetching (needed by new flow) + User borrower1 = buildUser("Alice", "Smith"); + Loan loan1 = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId1) + .withUserId(UUID.fromString(borrower1.getId())) + .open() + .create()) + .withItem(item1) + .withUser(borrower1); + + when(loanRepository.findOpenLoanForItem(item1)) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(loan1))); + + // Mock user enrichment (needed by new flow) + Collection enrichedLoans = Collections.singletonList(loan1.withUser(borrower1)); + when(userRepository.findUsersForLoans(any(Collection.class))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(enrichedLoans))); + + // Mock that there's already a hold request (the first one) + MultipleRecords existingHolds = new MultipleRecords<>( + Arrays.asList(firstHoldRequest, secondTitleLevelHoldRequest), 2); + when(requestRepository.findOpenRequestsByItemIds(Collections.singletonList(item1.getItemId()))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(existingHolds))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(secondTitleLevelHoldRequest).get(); + + assertTrue(result.succeeded()); + // No notices should be sent for second hold + verify(patronNoticeService, never()).acceptNoticeEvent(any()); + verify(eventPublisher, never()).publishHoldRequestedEvent(any()); + } + + @Test + @DisplayName("Edge case: item-level hold on Copy 1, then title-level hold should notify Copy 2 borrower") + void shouldNotifyBorrowerOfCopy2WhenCopy1HasItemLevelHoldAndTitleLevelHoldPlaced() throws Exception { + UUID instanceId = UUID.randomUUID(); + User alice = buildUser("Alice", "Smith"); + User bob = buildUser("Bob", "Jones"); + UUID itemId1 = UUID.randomUUID(); // Copy 1 + UUID itemId2 = UUID.randomUUID(); // Copy 2 + + Item item1 = Item.from(new ItemBuilder().withId(itemId1).create()); + Item item2 = Item.from(new ItemBuilder().withId(itemId2).create()); + + Loan aliceLoan = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId1) + .withUserId(UUID.fromString(alice.getId())) + .open() + .create()) + .withItem(item1) + .withUser(alice); + + Loan bobLoan = Loan.from(new LoanBuilder() + .withId(UUID.randomUUID()) + .withItemId(itemId2) + .withUserId(UUID.fromString(bob.getId())) + .open() + .create()) + .withItem(item2) + .withUser(bob); + + // item-level hold on Copy 1 + Request carolItemLevelHold = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withItemId(itemId1) + .withRequesterId(UUID.randomUUID()) + .create()); + + // title-level hold + Request davidTitleLevelHold = Request.from(new RequestBuilder() + .hold() + .withId(UUID.randomUUID()) + .withInstanceId(instanceId) + .withRequestLevel("Title") + .withRequesterId(UUID.randomUUID()) + .create()) + .withTlrSettingsConfiguration( + TlrSettingsConfiguration.from(new JsonObject() + .put("titleLevelRequestsFeatureEnabled", true)) + ); + + when(itemByInstanceIdFinder.getItemsByInstanceId(instanceId, false)) + .thenReturn(CompletableFuture.completedFuture( + Result.succeeded(Arrays.asList(item1, item2)) + )); + + // Mock for individual loan fetching (parallel queries) + when(loanRepository.findOpenLoanForItem(item1)) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(aliceLoan))); + when(loanRepository.findOpenLoanForItem(item2)) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(bobLoan))); + + // Mock for batch user enrichment + Collection enrichedLoans = Arrays.asList( + aliceLoan.withUser(alice), + bobLoan.withUser(bob) + ); + when(userRepository.findUsersForLoans(any(Collection.class))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(enrichedLoans))); + + // Mock for isFirstHoldRequestForItem checks + // Copy 1 has hold + MultipleRecords holdsOnItem1 = new MultipleRecords<>( + Arrays.asList(carolItemLevelHold, davidTitleLevelHold), 2); + when(requestRepository.findOpenRequestsByItemIds(Collections.singletonList(item1.getItemId()))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(holdsOnItem1))); + + // Copy 2 has no holds yet + MultipleRecords holdsOnItem2 = new MultipleRecords<>( + Collections.singletonList(davidTitleLevelHold), 1); + when(requestRepository.findOpenRequestsByItemIds(Collections.singletonList(item2.getItemId()))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(holdsOnItem2))); + + when(proxyRelationshipValidator + .hasActiveProxyRelationshipWithNotificationsSentToProxy(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(false))); + + when(eventPublisher.publishHoldRequestedEvent(any(Loan.class))) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(null))); + + when(patronNoticeService.acceptNoticeEvent(any())) + .thenReturn(CompletableFuture.completedFuture(Result.succeeded(null))); + + Result result = holdNoticeSender.sendHoldNoticeIfNeeded(davidTitleLevelHold).get(); + + assertTrue(result.succeeded()); + + verify(patronNoticeService, times(1)).acceptNoticeEvent(any()); + verify(eventPublisher, times(1)).publishHoldRequestedEvent(any()); + } +}