diff --git a/NEWS.md b/NEWS.md index c25416fd..8e13c0d3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,7 @@ +## 1.0.3 2024-12-30 +* DCB transaction status synchronization (MODTLR-112) +* Resolve central tenant ID dynamically (MODTLR-118) +* ## 1.0.2 2024-12-12 * Copy Secure Patron name when cloning users (MODTLR-116) * Support for intermediate requests (MODTLR-98) diff --git a/pom.xml b/pom.xml index 00af855e..e1b7720c 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.folio mod-tlr mod-tlr - 1.0.3-SNAPSHOT + 1.0.4-SNAPSHOT FOLIO mod-tlr module jar diff --git a/src/main/java/org/folio/client/feign/ConsortiaConfigurationClient.java b/src/main/java/org/folio/client/feign/ConsortiaConfigurationClient.java new file mode 100644 index 00000000..d9602ae5 --- /dev/null +++ b/src/main/java/org/folio/client/feign/ConsortiaConfigurationClient.java @@ -0,0 +1,13 @@ +package org.folio.client.feign; + +import org.folio.domain.dto.ConsortiaConfiguration; +import org.folio.spring.config.FeignClientConfiguration; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "consortia-configuration", url = "consortia-configuration", configuration = FeignClientConfiguration.class) +public interface ConsortiaConfigurationClient { + + @GetMapping + ConsortiaConfiguration getConfiguration(); +} diff --git a/src/main/java/org/folio/domain/Constants.java b/src/main/java/org/folio/domain/Constants.java deleted file mode 100644 index 35a1ad8e..00000000 --- a/src/main/java/org/folio/domain/Constants.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.folio.domain; - -import static org.folio.domain.dto.Request.RequestTypeEnum.HOLD; - -import org.folio.domain.dto.Request; - -import lombok.experimental.UtilityClass; - -@UtilityClass -public class Constants { - public static final String CENTRAL_TENANT_ID = "consortium"; - public static final Request.RequestTypeEnum PRIMARY_REQUEST_TYPE = HOLD; -} diff --git a/src/main/java/org/folio/listener/kafka/KafkaEventListener.java b/src/main/java/org/folio/listener/kafka/KafkaEventListener.java index f6e7b840..bc87dfed 100644 --- a/src/main/java/org/folio/listener/kafka/KafkaEventListener.java +++ b/src/main/java/org/folio/listener/kafka/KafkaEventListener.java @@ -1,114 +1,115 @@ package org.folio.listener.kafka; -import static org.folio.domain.Constants.CENTRAL_TENANT_ID; - import java.nio.charset.StandardCharsets; +import java.util.Map; import java.util.Optional; +import org.folio.domain.dto.Loan; import org.folio.domain.dto.Request; import org.folio.domain.dto.RequestsBatchUpdate; import org.folio.domain.dto.User; import org.folio.domain.dto.UserGroup; import org.folio.exception.KafkaEventDeserializationException; +import org.folio.service.ConsortiaService; import org.folio.service.KafkaEventHandler; +import org.folio.service.impl.LoanEventHandler; import org.folio.service.impl.RequestBatchUpdateEventHandler; import org.folio.service.impl.RequestEventHandler; import org.folio.service.impl.UserEventHandler; import org.folio.service.impl.UserGroupEventHandler; +import org.folio.spring.DefaultFolioExecutionContext; +import org.folio.spring.FolioExecutionContext; +import org.folio.spring.FolioModuleMetadata; import org.folio.spring.integration.XOkapiHeaders; +import org.folio.spring.scope.FolioExecutionContextSetter; import org.folio.spring.service.SystemUserScopedExecutionService; import org.folio.support.KafkaEvent; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Headers; import org.springframework.stereotype.Component; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @Component @Log4j2 +@RequiredArgsConstructor public class KafkaEventListener { private static final ObjectMapper objectMapper = new ObjectMapper(); private final RequestEventHandler requestEventHandler; + private final LoanEventHandler loanEventHandler; private final UserGroupEventHandler userGroupEventHandler; private final UserEventHandler userEventHandler; private final SystemUserScopedExecutionService systemUserScopedExecutionService; private final RequestBatchUpdateEventHandler requestBatchEventHandler; - - public KafkaEventListener(@Autowired RequestEventHandler requestEventHandler, - @Autowired RequestBatchUpdateEventHandler requestBatchEventHandler, - @Autowired SystemUserScopedExecutionService systemUserScopedExecutionService, - @Autowired UserGroupEventHandler userGroupEventHandler, - @Autowired UserEventHandler userEventHandler) { - - this.requestEventHandler = requestEventHandler; - this.systemUserScopedExecutionService = systemUserScopedExecutionService; - this.userGroupEventHandler = userGroupEventHandler; - this.requestBatchEventHandler = requestBatchEventHandler; - this.userEventHandler = userEventHandler; - } + private final ConsortiaService consortiaService; + private final FolioModuleMetadata folioModuleMetadata; @KafkaListener( topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.request", groupId = "${spring.kafka.consumer.group-id}" ) - public void handleRequestEvent(String eventString, MessageHeaders messageHeaders) { - log.debug("handleRequestEvent:: event: {}", () -> eventString); - KafkaEvent event = deserialize(eventString, messageHeaders, Request.class); - log.info("handleRequestEvent:: event received: {}", event::getId); - handleEvent(event, requestEventHandler); - log.info("handleRequestEvent:: event consumed: {}", event::getId); + public void handleRequestEvent(String eventString, @Headers Map messageHeaders) { + handleEvent(eventString, requestEventHandler, messageHeaders, Request.class); } @KafkaListener( - topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.request-queue-reordering", + topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.loan", groupId = "${spring.kafka.consumer.group-id}" ) - public void handleRequestBatchUpdateEvent(String eventString, MessageHeaders messageHeaders) { - log.debug("handleRequestBatchUpdateEvent:: event: {}", () -> eventString); - KafkaEvent event = deserialize(eventString, messageHeaders, RequestsBatchUpdate.class); - log.info("handleRequestBatchUpdateEvent:: event received: {}", event::getId); - handleEvent(event, requestBatchEventHandler); - log.info("handleRequestBatchUpdateEvent:: event consumed: {}", event::getId); + public void handleLoanEvent(String eventString, @Headers Map messageHeaders) { + handleEvent(eventString, loanEventHandler, messageHeaders, Loan.class); } - private void handleEvent(KafkaEvent event, KafkaEventHandler handler) { - try { - systemUserScopedExecutionService.executeAsyncSystemUserScoped(CENTRAL_TENANT_ID, - () -> handler.handle(event)); - } catch (Exception e) { - log.error("handleEvent:: Failed to handle Kafka event in tenant {}", CENTRAL_TENANT_ID); - } + @KafkaListener( + topicPattern = "${folio.environment}\\.\\w+\\.circulation\\.request-queue-reordering", + groupId = "${spring.kafka.consumer.group-id}" + ) + public void handleRequestBatchUpdateEvent(String eventString, @Headers Map messageHeaders) { + handleEvent(eventString, requestBatchEventHandler, messageHeaders, RequestsBatchUpdate.class); } @KafkaListener( topicPattern = "${folio.environment}\\.\\w+\\.users\\.userGroup", groupId = "${spring.kafka.consumer.group-id}" ) - public void handleUserGroupEvent(String eventString, MessageHeaders messageHeaders) { - KafkaEvent event = deserialize(eventString, messageHeaders, UserGroup.class); - - log.info("handleUserGroupEvent:: event received: {}", event::getId); - log.debug("handleUserGroupEvent:: event: {}", () -> event); - handleEvent(event, userGroupEventHandler); + public void handleUserGroupEvent(String eventString, @Headers Map messageHeaders) { + handleEvent(eventString, userGroupEventHandler, messageHeaders, UserGroup.class); } @KafkaListener( topicPattern = "${folio.environment}\\.\\w+\\.users\\.users", groupId = "${spring.kafka.consumer.group-id}" ) - public void handleUserEvent(String eventString, MessageHeaders messageHeaders) { - KafkaEvent event = deserialize(eventString, messageHeaders, User.class); + public void handleUserEvent(String eventString, @Headers Map messageHeaders) { + handleEvent(eventString, userEventHandler, messageHeaders, User.class); + } + + private void handleEvent(String eventString, KafkaEventHandler handler, + Map messageHeaders, Class payloadType) { - log.info("handleUserEvent:: event received: {}", event::getId); - handleEvent(event, userEventHandler); + log.debug("handleEvent:: event: {}", () -> eventString); + KafkaEvent event = deserialize(eventString, messageHeaders, payloadType); + log.info("handleEvent:: event received: {}", event::getId); + + FolioExecutionContext context = DefaultFolioExecutionContext.fromMessageHeaders( + folioModuleMetadata, messageHeaders); + + try (FolioExecutionContextSetter contextSetter = new FolioExecutionContextSetter(context)) { + String centralTenantId = consortiaService.getCentralTenantId(); + systemUserScopedExecutionService.executeAsyncSystemUserScoped(centralTenantId, + () -> handler.handle(event)); + } catch (Exception e) { + log.error("handleEvent:: failed to handle event {}", event.getId(), e); + } + log.info("handleEvent:: event consumed: {}", event::getId); } - private static KafkaEvent deserialize(String eventString, MessageHeaders messageHeaders, + private static KafkaEvent deserialize(String eventString, Map messageHeaders, Class dataType) { try { @@ -125,7 +126,7 @@ private static KafkaEvent deserialize(String eventString, MessageHeaders } } - private static String getHeaderValue(MessageHeaders headers, String headerName) { + private static String getHeaderValue(Map headers, String headerName) { log.debug("getHeaderValue:: headers: {}, headerName: {}", () -> headers, () -> headerName); var headerValue = headers.get(headerName); var value = headerValue == null diff --git a/src/main/java/org/folio/repository/EcsTlrRepository.java b/src/main/java/org/folio/repository/EcsTlrRepository.java index 1679554a..4c45fde6 100644 --- a/src/main/java/org/folio/repository/EcsTlrRepository.java +++ b/src/main/java/org/folio/repository/EcsTlrRepository.java @@ -14,4 +14,5 @@ public interface EcsTlrRepository extends JpaRepository { Optional findByPrimaryRequestId(UUID primaryRequestId); Optional findByInstanceId(UUID instanceId); List findByPrimaryRequestIdIn(List primaryRequestIds); + List findByItemId(UUID itemId); } diff --git a/src/main/java/org/folio/service/ConsortiaService.java b/src/main/java/org/folio/service/ConsortiaService.java index 562d9749..f676f228 100644 --- a/src/main/java/org/folio/service/ConsortiaService.java +++ b/src/main/java/org/folio/service/ConsortiaService.java @@ -8,4 +8,5 @@ public interface ConsortiaService { TenantCollection getAllConsortiumTenants(String consortiumId); Collection getAllConsortiumTenants(); + String getCentralTenantId(); } diff --git a/src/main/java/org/folio/service/DcbService.java b/src/main/java/org/folio/service/DcbService.java index 7b8cb372..5bf75405 100644 --- a/src/main/java/org/folio/service/DcbService.java +++ b/src/main/java/org/folio/service/DcbService.java @@ -13,7 +13,8 @@ public interface DcbService { void createBorrowerTransaction(EcsTlrEntity ecsTlr, Request request); void createBorrowingPickupTransaction(EcsTlrEntity ecsTlr, Request request); void createPickupTransaction(EcsTlrEntity ecsTlr, Request request); + void updateTransactionStatuses(TransactionStatus.StatusEnum newStatus, EcsTlrEntity ecsTlr); TransactionStatusResponse getTransactionStatus(UUID transactionId, String tenantId); - TransactionStatusResponse updateTransactionStatus(UUID transactionId, - TransactionStatus.StatusEnum newStatus, String tenantId); + void updateTransactionStatus(UUID transactionId, TransactionStatus.StatusEnum newStatus, + String tenantId); } diff --git a/src/main/java/org/folio/service/impl/ConsortiaServiceImpl.java b/src/main/java/org/folio/service/impl/ConsortiaServiceImpl.java index e328de0b..cbc145ef 100644 --- a/src/main/java/org/folio/service/impl/ConsortiaServiceImpl.java +++ b/src/main/java/org/folio/service/impl/ConsortiaServiceImpl.java @@ -6,6 +6,8 @@ import java.util.Optional; import org.folio.client.feign.ConsortiaClient; +import org.folio.client.feign.ConsortiaConfigurationClient; +import org.folio.domain.dto.ConsortiaConfiguration; import org.folio.domain.dto.Tenant; import org.folio.domain.dto.TenantCollection; import org.folio.domain.dto.UserTenant; @@ -21,6 +23,7 @@ @RequiredArgsConstructor public class ConsortiaServiceImpl implements ConsortiaService { private final ConsortiaClient consortiaClient; + private final ConsortiaConfigurationClient consortiaConfigurationClient; private final UserTenantsService userTenantsService; @Override @@ -40,4 +43,14 @@ public Collection getAllConsortiumTenants() { log.info("getAllConsortiumTenants:: found {} consortium tenants", tenants::size); return tenants; } + + @Override + public String getCentralTenantId() { + log.info("getCentralTenantId:: resolving central tenant ID"); + String centralTenantId = Optional.ofNullable(consortiaConfigurationClient.getConfiguration()) + .map(ConsortiaConfiguration::getCentralTenantId) + .orElseThrow(); + log.info("getCentralTenantId:: central tenant ID: {}", centralTenantId); + return centralTenantId; + } } diff --git a/src/main/java/org/folio/service/impl/DcbServiceImpl.java b/src/main/java/org/folio/service/impl/DcbServiceImpl.java index 99017e46..3408e23f 100644 --- a/src/main/java/org/folio/service/impl/DcbServiceImpl.java +++ b/src/main/java/org/folio/service/impl/DcbServiceImpl.java @@ -4,6 +4,13 @@ import static org.folio.domain.dto.DcbTransaction.RoleEnum.BORROWING_PICKUP; import static org.folio.domain.dto.DcbTransaction.RoleEnum.LENDER; import static org.folio.domain.dto.DcbTransaction.RoleEnum.PICKUP; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.AWAITING_PICKUP; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.CANCELLED; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.CLOSED; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.CREATED; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.ITEM_CHECKED_IN; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.ITEM_CHECKED_OUT; +import static org.folio.domain.dto.TransactionStatus.StatusEnum.OPEN; import java.util.UUID; @@ -14,6 +21,7 @@ import org.folio.domain.dto.DcbTransaction.RoleEnum; import org.folio.domain.dto.Request; import org.folio.domain.dto.TransactionStatus; +import org.folio.domain.dto.TransactionStatus.StatusEnum; import org.folio.domain.dto.TransactionStatusResponse; import org.folio.domain.entity.EcsTlrEntity; import org.folio.service.DcbService; @@ -21,6 +29,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import feign.FeignException; import lombok.extern.log4j.Log4j2; @Service @@ -114,18 +123,6 @@ public TransactionStatusResponse getTransactionStatus(UUID transactionId, String () -> dcbTransactionClient.getDcbTransactionStatus(transactionId.toString())); } - @Override - public TransactionStatusResponse updateTransactionStatus(UUID transactionId, - TransactionStatus.StatusEnum newStatus, String tenantId) { - - log.info("updateTransactionStatus:: transactionId={}, newStatus={}, tenantId={}", - transactionId, newStatus, tenantId); - - return executionService.executeSystemUserScoped(tenantId, - () -> dcbTransactionClient.changeDcbTransactionStatus( - transactionId.toString(), new TransactionStatus().status(newStatus))); - } - @Override public void createTransactions(EcsTlrEntity ecsTlr, Request secondaryRequest) { log.info("createTransactions:: creating transactions for ECS TLR {}", ecsTlr::getId); @@ -143,4 +140,89 @@ public void createTransactions(EcsTlrEntity ecsTlr, Request secondaryRequest) { } } + @Override + public void updateTransactionStatuses(TransactionStatus.StatusEnum newStatus, EcsTlrEntity ecsTlr) { + log.info("updateTransactionStatuses:: updating primary transaction status to {}", newStatus::getValue); + updateTransactionStatus(ecsTlr.getPrimaryRequestDcbTransactionId(), newStatus, + ecsTlr.getPrimaryRequestTenantId()); + + log.info("updateTransactionStatuses:: updating intermediate transaction status to {}", newStatus::getValue); + updateTransactionStatus(ecsTlr.getIntermediateRequestDcbTransactionId(), newStatus, + ecsTlr.getIntermediateRequestTenantId()); + + log.info("updateTransactionStatuses:: updating secondary transaction status to {}", newStatus::getValue); + updateTransactionStatus(ecsTlr.getSecondaryRequestDcbTransactionId(), newStatus, + ecsTlr.getSecondaryRequestTenantId()); + } + + @Override + public void updateTransactionStatus(UUID transactionId, StatusEnum newStatus, String tenantId) { + if (transactionId == null) { + log.info("updateTransactionStatus:: transaction ID is null, doing nothing"); + return; + } + if (tenantId == null) { + log.info("updateTransactionStatus:: tenant ID is null, doing nothing"); + return; + } + + try { + if (isTransactionStatusChangeAllowed(transactionId, newStatus, tenantId)) { + log.info("updateTransactionStatus: changing status of transaction {} in tenant {} to {}", + transactionId, tenantId, newStatus.getValue()); + + executionService.executeSystemUserScoped(tenantId, + () -> dcbTransactionClient.changeDcbTransactionStatus(transactionId.toString(), + new TransactionStatus().status(newStatus))); + } + } catch (FeignException.NotFound e) { + log.error("updateTransactionStatus:: transaction {} not found: {}", transactionId, e.getMessage()); + } catch (Exception e) { + log.error("updateTransactionStatus:: failed to update transaction status: {}", e::getMessage); + log.debug("updateTransactionStatus:: ", e); + } + } + + private boolean isTransactionStatusChangeAllowed(UUID transactionId, StatusEnum newStatus, + String tenantId) { + + TransactionStatusResponse transaction = getTransactionStatus(transactionId, tenantId); + RoleEnum transactionRole = RoleEnum.fromValue(transaction.getRole().getValue()); + StatusEnum currentStatus = StatusEnum.fromValue(transaction.getStatus().getValue()); + + return isTransactionStatusChangeAllowed(transactionRole, currentStatus, newStatus); + } + + private static boolean isTransactionStatusChangeAllowed(RoleEnum role, StatusEnum oldStatus, + StatusEnum newStatus) { + + log.info("isTransactionStatusChangeAllowed:: role={}, oldStatus={}, newStatus={}", role, + oldStatus, newStatus); + + boolean isStatusChangeAllowed = false; + + if (role == LENDER) { + isStatusChangeAllowed = (oldStatus == CREATED && newStatus == OPEN) || + (oldStatus == OPEN && newStatus == AWAITING_PICKUP) || + (oldStatus == AWAITING_PICKUP && newStatus == ITEM_CHECKED_OUT) || + (oldStatus == ITEM_CHECKED_OUT && newStatus == ITEM_CHECKED_IN) || + (oldStatus != CANCELLED && newStatus == CANCELLED); + } + else if (role == BORROWER) { + isStatusChangeAllowed = (oldStatus == CREATED && newStatus == OPEN) || + (oldStatus == OPEN && newStatus == AWAITING_PICKUP) || + (oldStatus == AWAITING_PICKUP && newStatus == ITEM_CHECKED_OUT) || + (oldStatus == ITEM_CHECKED_OUT && newStatus == ITEM_CHECKED_IN) || + (oldStatus == ITEM_CHECKED_IN && newStatus == CLOSED) || + (oldStatus != CANCELLED && newStatus == CANCELLED); + } + else if (role == BORROWING_PICKUP || role == PICKUP) { + isStatusChangeAllowed = (oldStatus == CREATED && newStatus == OPEN) || + (oldStatus == ITEM_CHECKED_IN && newStatus == CLOSED) || + (oldStatus != CANCELLED && newStatus == CANCELLED); + } + log.info("isTransactionStatusChangeAllowed:: status change is allowed: {}", isStatusChangeAllowed); + return isStatusChangeAllowed; + } + } diff --git a/src/main/java/org/folio/service/impl/LoanEventHandler.java b/src/main/java/org/folio/service/impl/LoanEventHandler.java new file mode 100644 index 00000000..2f996126 --- /dev/null +++ b/src/main/java/org/folio/service/impl/LoanEventHandler.java @@ -0,0 +1,146 @@ +package org.folio.service.impl; + +import static org.folio.domain.dto.TransactionStatusResponse.RoleEnum.BORROWING_PICKUP; +import static org.folio.domain.dto.TransactionStatusResponse.RoleEnum.LENDER; +import static org.folio.domain.dto.TransactionStatusResponse.RoleEnum.PICKUP; +import static org.folio.domain.dto.TransactionStatusResponse.StatusEnum.CLOSED; +import static org.folio.domain.dto.TransactionStatusResponse.StatusEnum.ITEM_CHECKED_IN; +import static org.folio.domain.dto.TransactionStatusResponse.StatusEnum.ITEM_CHECKED_OUT; +import static org.folio.support.KafkaEvent.EventType.UPDATED; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; + +import org.folio.domain.dto.Loan; +import org.folio.domain.dto.TransactionStatus.StatusEnum; +import org.folio.domain.dto.TransactionStatusResponse; +import org.folio.domain.dto.TransactionStatusResponse.RoleEnum; +import org.folio.domain.entity.EcsTlrEntity; +import org.folio.repository.EcsTlrRepository; +import org.folio.service.DcbService; +import org.folio.service.KafkaEventHandler; +import org.folio.support.KafkaEvent; +import org.springframework.stereotype.Service; + +import lombok.AllArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@AllArgsConstructor +@Service +@Log4j2 +public class LoanEventHandler implements KafkaEventHandler { + private static final String LOAN_ACTION_CHECKED_IN = "checkedin"; + private static final EnumSet + RELEVANT_TRANSACTION_STATUSES_FOR_CHECK_IN = EnumSet.of(ITEM_CHECKED_OUT, ITEM_CHECKED_IN, CLOSED); + + private final DcbService dcbService; + private final EcsTlrRepository ecsTlrRepository; + + @Override + public void handle(KafkaEvent event) { + log.info("handle:: processing loan event: {}", event::getId); + if (event.getType() == UPDATED) { + handleUpdateEvent(event); + } else { + log.info("handle:: ignoring event {} of unsupported type: {}", event::getId, event::getType); + } + log.info("handle:: loan event processed: {}", event::getId); + } + + private void handleUpdateEvent(KafkaEvent event) { + Loan loan = event.getData().getNewVersion(); + String loanAction = loan.getAction(); + log.info("handle:: loan action: {}", loanAction); + if (LOAN_ACTION_CHECKED_IN.equals(loanAction)) { + log.info("handleUpdateEvent:: processing loan check-in event: {}", event::getId); + handleCheckInEvent(event); + } else { + log.info("handleUpdateEvent:: ignoring loan update event with unsupported loan action: {}", loanAction); + } + } + + private void handleCheckInEvent(KafkaEvent event) { + updateEcsTlr(event.getData().getNewVersion(), event.getTenant()); + } + + private void updateEcsTlr(Loan loan, String tenantId) { + Collection ecsTlrs = findEcsTlrs(loan); + for (EcsTlrEntity ecsTlr : ecsTlrs) { + log.info("updateEcsTlr:: checking ECS TLR {}", ecsTlr::getId); + String primaryTenantId = ecsTlr.getPrimaryRequestTenantId(); + String secondaryTenantId = ecsTlr.getSecondaryRequestTenantId(); + UUID primaryTransactionId = ecsTlr.getPrimaryRequestDcbTransactionId(); + UUID secondaryTransactionId = ecsTlr.getSecondaryRequestDcbTransactionId(); + + if (primaryTransactionId == null || secondaryTransactionId == null) { + log.info("updateEcsTlr:: ECS TLR does not have primary/secondary transaction, skipping"); + continue; + } + + boolean eventTenantIdIsPrimaryTenantId = tenantId.equals(primaryTenantId); + boolean eventTenantIdIsSecondaryTenantId = tenantId.equals(secondaryTenantId); + if (!(eventTenantIdIsPrimaryTenantId || eventTenantIdIsSecondaryTenantId)) { + log.info("updateEcsTlr:: event tenant ID does not match ECS TLR's primary/secondary request " + + "tenant ID, skipping"); + continue; + } + + TransactionStatusResponse primaryTransaction = dcbService.getTransactionStatus( + primaryTransactionId, primaryTenantId); + TransactionStatusResponse.StatusEnum primaryTransactionStatus = primaryTransaction.getStatus(); + RoleEnum primaryTransactionRole = primaryTransaction.getRole(); + log.info("updateEcsTlr:: primary request transaction: status={}, role={}", + primaryTransactionStatus, primaryTransactionRole); + if (!RELEVANT_TRANSACTION_STATUSES_FOR_CHECK_IN.contains(primaryTransactionStatus)) { + log.info("updateEcsTlrForLoan:: irrelevant primary request transaction status: {}", + primaryTransaction); + continue; + } + + TransactionStatusResponse secondaryTransaction = dcbService.getTransactionStatus( + secondaryTransactionId, secondaryTenantId); + TransactionStatusResponse.StatusEnum secondaryTransactionStatus = secondaryTransaction.getStatus(); + RoleEnum secondaryTransactionRole = secondaryTransaction.getRole(); + log.info("updateEcsTlr:: secondary request transaction: status={}, role={}", + secondaryTransactionStatus, secondaryTransactionRole); + if (!RELEVANT_TRANSACTION_STATUSES_FOR_CHECK_IN.contains(secondaryTransactionStatus)) { + log.info("updateEcsTlr:: irrelevant secondary request transaction status: {}", + secondaryTransactionStatus); + continue; + } + + if (eventTenantIdIsPrimaryTenantId && + (primaryTransactionRole == BORROWING_PICKUP || primaryTransactionRole == PICKUP) && + (primaryTransactionStatus == ITEM_CHECKED_OUT || primaryTransactionStatus == ITEM_CHECKED_IN) && + secondaryTransactionRole == LENDER && secondaryTransactionStatus == ITEM_CHECKED_OUT) { + + log.info("updateEcsTlr:: check-in happened in primary request tenant ({}), updating transactions", + primaryTenantId); + dcbService.updateTransactionStatuses(StatusEnum.ITEM_CHECKED_IN, ecsTlr); + return; + } + else if (eventTenantIdIsSecondaryTenantId && secondaryTransactionRole == LENDER && + (secondaryTransactionStatus == ITEM_CHECKED_IN || secondaryTransactionStatus == CLOSED) && + (primaryTransactionRole == BORROWING_PICKUP || primaryTransactionRole == PICKUP) && + primaryTransactionStatus == ITEM_CHECKED_IN) { + + log.info("updateEcsTlr:: check-in happened in secondary request tenant ({}), updating transactions", secondaryTenantId); + dcbService.updateTransactionStatuses(StatusEnum.CLOSED, ecsTlr); + return; + } + log.info("updateEcsTlr:: ECS TLR {} does not match loan update event, skipping", ecsTlr::getId); + } + log.info("updateEcsTlr:: suitable ECS TLR for loan {} in tenant {} was not found", loan.getId(), tenantId); + } + + private Collection findEcsTlrs(Loan loan) { + log.info("findEcsTlrs:: searching ECS TLRs for item {}", loan::getItemId); + List ecsTlrs = ecsTlrRepository.findByItemId(UUID.fromString(loan.getItemId())); + log.info("findEcsTlrs:: found {} ECS TLRs", ecsTlrs::size); + + return ecsTlrs; + } + +} diff --git a/src/main/java/org/folio/service/impl/RequestEventHandler.java b/src/main/java/org/folio/service/impl/RequestEventHandler.java index f8087ace..e367cc41 100644 --- a/src/main/java/org/folio/service/impl/RequestEventHandler.java +++ b/src/main/java/org/folio/service/impl/RequestEventHandler.java @@ -30,7 +30,6 @@ import org.folio.support.KafkaEvent; import org.springframework.stereotype.Service; -import feign.FeignException; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -149,7 +148,7 @@ private static Optional determineNewTransactionSta oldRequestStatus, newRequestStatus); if (newRequestStatus == oldRequestStatus) { - log.info("determineNewTransactionStatus:: request status did not change"); + log.info("determineNewTransactionStatus:: request status did not change, doing nothing"); return Optional.empty(); } @@ -171,51 +170,7 @@ private static Optional determineNewTransactionSta private void updateTransactionStatuses(KafkaEvent event, EcsTlrEntity ecsTlr) { determineNewTransactionStatus(event) - .ifPresent(newStatus -> updateTransactionStatuses(newStatus, ecsTlr)); - } - - private void updateTransactionStatuses(TransactionStatus.StatusEnum newStatus, EcsTlrEntity ecsTlr) { - log.info("updateTransactionStatuses:: updating primary transaction status to {}", newStatus::getValue); - updateTransactionStatus(ecsTlr.getPrimaryRequestDcbTransactionId(), newStatus, - ecsTlr.getPrimaryRequestTenantId()); - - log.info("updateTransactionStatuses:: updating intermediate transaction status to {}", newStatus::getValue); - updateTransactionStatus(ecsTlr.getIntermediateRequestDcbTransactionId(), newStatus, - ecsTlr.getIntermediateRequestTenantId()); - - log.info("updateTransactionStatuses:: updating secondary transaction status to {}", newStatus::getValue); - updateTransactionStatus(ecsTlr.getSecondaryRequestDcbTransactionId(), newStatus, - ecsTlr.getSecondaryRequestTenantId()); - } - - private void updateTransactionStatus(UUID transactionId, - TransactionStatus.StatusEnum newStatus, String tenantId) { - - if (transactionId == null) { - log.info("updateTransactionStatus:: transaction ID is null, doing nothing"); - return; - } - if (tenantId == null) { - log.info("updateTransactionStatus:: tenant ID is null, doing nothing"); - return; - } - - try { - var currentStatus = dcbService.getTransactionStatus(transactionId, tenantId).getStatus(); - log.info("updateTransactionStatus:: current transaction status: {}", currentStatus); - if (newStatus.getValue().equals(currentStatus.getValue())) { - log.info("updateTransactionStatus:: transaction status did not change, doing nothing"); - return; - } - log.info("updateTransactionStatus: changing status of transaction {} in tenant {} from {} to {}", - transactionId, tenantId, currentStatus.getValue(), newStatus.getValue()); - dcbService.updateTransactionStatus(transactionId, newStatus, tenantId); - } catch (FeignException.NotFound e) { - log.error("updateTransactionStatus:: transaction {} not found: {}", transactionId, e.getMessage()); - } catch (Exception e) { - log.error("updateTransactionStatus:: failed to update transaction status: {}", e::getMessage); - log.debug("updateTransactionStatus:: ", e); - } + .ifPresent(newStatus -> dcbService.updateTransactionStatuses(newStatus, ecsTlr)); } private void propagateChangesFromPrimaryToSecondaryRequest(EcsTlrEntity ecsTlr, diff --git a/src/main/java/org/folio/service/impl/RequestServiceImpl.java b/src/main/java/org/folio/service/impl/RequestServiceImpl.java index 313d4b8e..242c7c61 100644 --- a/src/main/java/org/folio/service/impl/RequestServiceImpl.java +++ b/src/main/java/org/folio/service/impl/RequestServiceImpl.java @@ -234,7 +234,7 @@ public CirculationItem createCirculationItem(Request request, String inventoryTe .effectiveLocationId(item.getEffectiveLocationId()) .lendingLibraryCode("TEST_CODE"); - log.info("createCirculationItem:: creating circulation item {}", circulationItem.toString()); + log.info("createCirculationItem:: creating circulation item {}", itemId); return circulationItemClient.createCirculationItem(itemId, circulationItem); } diff --git a/src/main/resources/swagger.api/ecs-tlr.yaml b/src/main/resources/swagger.api/ecs-tlr.yaml index dd60c16e..52bc6adb 100644 --- a/src/main/resources/swagger.api/ecs-tlr.yaml +++ b/src/main/resources/swagger.api/ecs-tlr.yaml @@ -90,9 +90,11 @@ components: transactionStatusResponse: $ref: 'schemas/transactionStatusResponse.yaml#/TransactionStatusResponse' tenant: - $ref: 'schemas/tenant.yaml#/Tenant' + $ref: 'schemas/consortia/tenant.yaml#/Tenant' tenants: - $ref: 'schemas/tenant.yaml#/TenantCollection' + $ref: 'schemas/consortia/tenant.yaml#/TenantCollection' + consortiaConfiguration: + $ref: 'schemas/consortia/consortiaConfiguration.yaml#/ConsortiaConfiguration' publicationRequest: $ref: 'schemas/publication.yaml#/PublicationRequest' publicationResponse: @@ -103,6 +105,8 @@ components: $ref: 'schemas/request.json' requests: $ref: 'schemas/requests.json' + loan: + $ref: 'schemas/loan.json' searchInstancesResponse: $ref: 'schemas/search/searchInstancesResponse.yaml' searchItemResponse: diff --git a/src/main/resources/swagger.api/schemas/consortia/consortiaConfiguration.yaml b/src/main/resources/swagger.api/schemas/consortia/consortiaConfiguration.yaml new file mode 100644 index 00000000..10b01b6d --- /dev/null +++ b/src/main/resources/swagger.api/schemas/consortia/consortiaConfiguration.yaml @@ -0,0 +1,9 @@ +ConsortiaConfiguration: + type: "object" + description: "Consortia Configuration" + properties: + id: + type: "string" + format: "uuid" + centralTenantId: + type: "string" \ No newline at end of file diff --git a/src/main/resources/swagger.api/schemas/tenant.yaml b/src/main/resources/swagger.api/schemas/consortia/tenant.yaml similarity index 100% rename from src/main/resources/swagger.api/schemas/tenant.yaml rename to src/main/resources/swagger.api/schemas/consortia/tenant.yaml diff --git a/src/main/resources/swagger.api/schemas/loan.json b/src/main/resources/swagger.api/schemas/loan.json new file mode 100644 index 00000000..1ab9653f --- /dev/null +++ b/src/main/resources/swagger.api/schemas/loan.json @@ -0,0 +1,166 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "Loan", + "description": "Links the item with the patron and applies certain conditions based on policies", + "properties": { + "id": { + "description": "Unique ID (generated UUID) of the loan", + "type": "string" + }, + "userId": { + "description": "ID of the patron the item was lent to. Required for open loans, not required for closed loans (for anonymization).", + "type": "string" + }, + "proxyUserId": { + "description": "ID of the user representing a proxy for the patron", + "type": "string", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "itemId": { + "description": "ID of the item lent to the patron", + "type": "string" + }, + "itemEffectiveLocationIdAtCheckOut": { + "description": "The effective location, at the time of checkout, of the item loaned to the patron.", + "type": "string", + "$ref": "uuid.json" + }, + "status": { + "description": "Overall status of the loan", + "type": "object", + "properties": { + "name": { + "description": "Name of the status (currently can be any value, values commonly used are Open and Closed)", + "type": "string" + } + } + }, + "loanDate": { + "description": "Date time when the loan began (typically represented according to rfc3339 section-5.6. Has not had the date-time format validation applied as was not supported at point of introduction and would now be a breaking change)", + "type": "string" + }, + "dueDate": { + "description": "Date time when the item is due to be returned", + "type": "string", + "format": "date-time" + }, + "returnDate": { + "description": "Date time when the item is returned and the loan ends (typically represented according to rfc3339 section-5.6. Has not had the date-time format validation applied as was not supported at point of introduction and would now be a breaking change)", + "type": "string" + }, + "systemReturnDate" : { + "description": "Date time when the returned item is actually processed", + "type": "string", + "format": "date-time" + }, + "action": { + "description": "Last action performed on a loan (currently can be any value, values commonly used are checkedout and checkedin)", + "type": "string" + }, + "actionComment": { + "description": "Comment to last action performed on a loan", + "type": "string" + }, + "itemStatus": { + "description": "Last item status used in relation to this loan (currently can be any value, values commonly used are Checked out and Available)", + "type": "string" + }, + "renewalCount": { + "description": "Count of how many times a loan has been renewed (incremented by the client)", + "type": "integer" + }, + "loanPolicyId": { + "description": "ID of last policy used in relation to this loan", + "type": "string" + }, + "checkoutServicePointId": { + "description": "ID of the Service Point where the last checkout occured", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "checkinServicePointId": { + "description": "ID of the Service Point where the last checkin occured", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "patronGroupIdAtCheckout": { + "description": "Patron Group Id at checkout", + "type": "string" + }, + "dueDateChangedByRecall": { + "description": "Indicates whether or not this loan had its due date modified by a recall on the loaned item", + "type": "boolean" + }, + "isDcb": { + "description": "Indicates whether or not this loan is associated for DCB use case", + "type": "boolean" + }, + "declaredLostDate" : { + "description": "Date and time the item was declared lost during this loan", + "type": "string", + "format": "date-time" + }, + "claimedReturnedDate": { + "description": "Date and time the item was claimed returned for this loan", + "type": "string", + "format": "date-time" + }, + "overdueFinePolicyId": { + "description": "ID of overdue fines policy at the time the item is check-in or renewed", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "lostItemPolicyId": { + "description": "ID of lost item policy which determines when the item ages to lost and the associated fees or the associated fees if the patron declares the item lost.", + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", + "type": "string" + }, + "metadata": { + "description": "Metadata about creation and changes to loan, provided by the server (client should not provide)", + "type": "object", + "$ref": "metadata.json" + }, + "agedToLostDelayedBilling": { + "description": "Aged to Lost Delayed Billing processing", + "type": "object", + "properties": { + "lostItemHasBeenBilled": { + "description": "Indicates if the aged to lost fee has been billed (for use where delayed billing is set up)", + "type": "boolean" + }, + "dateLostItemShouldBeBilled": { + "description": "Indicates when the aged to lost fee should be billed (for use where delayed billing is set up)", + "type": "string", + "format": "date-time" + }, + "agedToLostDate": { + "description": "Date and time the item was aged to lost for this loan", + "type": "string", + "format": "date-time" + } + } + }, + "reminders" : { + "description": "Information about reminders for overdue loan", + "type": "object", + "properties": { + "lastFeeBilled": { + "description": "Information about the most recent reminder fee billing", + "type": "object", + "properties": { + "number": { + "description": "Last reminder fee billed, sequence number", + "type": "integer" + }, + "date": { + "description": "Last reminder fee billed, date", + "type": "string", + "format": "date-time" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/test/java/org/folio/api/BaseIT.java b/src/test/java/org/folio/api/BaseIT.java index 4e905b89..f836fe5d 100644 --- a/src/test/java/org/folio/api/BaseIT.java +++ b/src/test/java/org/folio/api/BaseIT.java @@ -1,5 +1,6 @@ package org.folio.api; +import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toMap; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -16,6 +17,8 @@ import org.apache.kafka.clients.admin.KafkaAdminClient; import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; import org.folio.spring.FolioExecutionContext; import org.folio.spring.FolioModuleMetadata; import org.folio.spring.integration.XOkapiHeaders; @@ -35,7 +38,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.messaging.MessageHeaders; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.DynamicPropertyRegistry; @@ -74,6 +76,7 @@ public class BaseIT { private static final String FOLIO_ENVIRONMENT = "folio"; protected static final String HEADER_TENANT = "x-okapi-tenant"; + protected static final String USED_ID = "08d51c7a-0f36-4f3d-9e35-d285612a23df"; protected static final String TOKEN = "test_token"; protected static final String TENANT_ID_CONSORTIUM = "consortium"; // central tenant protected static final String TENANT_ID_UNIVERSITY = "university"; @@ -156,22 +159,48 @@ public static String getOkapiUrl() { protected static void setUpTenant(MockMvc mockMvc) { mockMvc.perform(MockMvcRequestBuilders.post("/_/tenant") .content(asJsonString(new TenantAttributes().moduleTo("mod-tlr"))) - .headers(defaultHeaders()) + .headers(defaultHeadersForRequest()) .contentType(APPLICATION_JSON)).andExpect(status().isNoContent()); } - public static HttpHeaders defaultHeaders() { + public static HttpHeaders defaultHeadersForRequest() { final HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.setContentType(APPLICATION_JSON); - httpHeaders.add(XOkapiHeaders.TENANT, TENANT_ID_CONSORTIUM); - httpHeaders.add(XOkapiHeaders.URL, wireMockServer.baseUrl()); - httpHeaders.add(XOkapiHeaders.TOKEN, TOKEN); - httpHeaders.add(XOkapiHeaders.USER_ID, "08d51c7a-0f36-4f3d-9e35-d285612a23df"); - + buildHeaders().forEach(httpHeaders::add); return httpHeaders; } + protected static Collection
buildHeadersForKafkaProducer(String tenant) { + return buildKafkaHeaders(tenant) + .entrySet() + .stream() + .map(entry -> new RecordHeader(entry.getKey(), (byte[]) entry.getValue())) + .collect(toList()); + } + + protected static Map buildKafkaHeaders(String tenantId) { + Map headers = buildHeaders(tenantId); + headers.put("folio.tenantId", tenantId); + + return headers.entrySet() + .stream() + .collect(toMap(Map.Entry::getKey, entry -> entry.getValue().getBytes())); + } + + protected static Map buildHeaders() { + return buildHeaders(TENANT_ID_CONSORTIUM); + } + + protected static Map buildHeaders(String tenantId) { + Map headers = new HashMap<>(); + headers.put(XOkapiHeaders.TENANT, tenantId); + headers.put(XOkapiHeaders.URL, wireMockServer.baseUrl()); + headers.put(XOkapiHeaders.TOKEN, TOKEN); + headers.put(XOkapiHeaders.USER_ID, USED_ID); + headers.put(XOkapiHeaders.REQUEST_ID, randomId()); + return headers; + } + @SneakyThrows public static String asJsonString(Object value) { return OBJECT_MAPPER.writeValueAsString(value); @@ -228,7 +257,7 @@ protected static String randomId() { } private static Map> buildDefaultHeaders() { - return new HashMap<>(defaultHeaders().entrySet() + return new HashMap<>(defaultHeadersForRequest().entrySet() .stream() .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); } @@ -260,11 +289,4 @@ private static String buildTopicName(String env, String tenant, String module, S return String.format("%s.%s.%s.%s", env, tenant, module, objectType); } - protected MessageHeaders getMessageHeaders(String tenantName, String tenantId) { - Map header = new HashMap<>(); - header.put(XOkapiHeaders.TENANT, tenantName.getBytes()); - header.put("folio.tenantId", tenantId); - - return new MessageHeaders(header); - } } diff --git a/src/test/java/org/folio/controller/KafkaEventListenerTest.java b/src/test/java/org/folio/controller/KafkaEventListenerTest.java index 8e896c79..3462e274 100644 --- a/src/test/java/org/folio/controller/KafkaEventListenerTest.java +++ b/src/test/java/org/folio/controller/KafkaEventListenerTest.java @@ -27,8 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.Date; -import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -37,7 +37,7 @@ import org.apache.kafka.clients.consumer.OffsetAndMetadata; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.TopicPartition; -import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.Header; import org.awaitility.Awaitility; import org.folio.api.BaseIT; import org.folio.domain.dto.DcbItem; @@ -622,11 +622,8 @@ private void publishEvent(String tenant, String topic, KafkaEvent event) @SneakyThrows private void publishEvent(String tenant, String topic, String payload) { - kafkaTemplate.send(new ProducerRecord<>(topic, 0, randomId(), payload, - List.of( - new RecordHeader(XOkapiHeaders.TENANT, tenant.getBytes()), - new RecordHeader("folio.tenantId", randomId().getBytes()) - ))) + Collection
headers = buildHeadersForKafkaProducer(tenant); + kafkaTemplate.send(new ProducerRecord<>(topic, 0, randomId(), payload, headers)) .get(10, SECONDS); } diff --git a/src/test/java/org/folio/listener/KafkaEventListenerTest.java b/src/test/java/org/folio/listener/KafkaEventListenerTest.java index 759f8272..f1e698e5 100644 --- a/src/test/java/org/folio/listener/KafkaEventListenerTest.java +++ b/src/test/java/org/folio/listener/KafkaEventListenerTest.java @@ -8,6 +8,8 @@ import java.util.Map; import org.folio.listener.kafka.KafkaEventListener; +import org.folio.service.ConsortiaService; +import org.folio.service.impl.LoanEventHandler; import org.folio.service.impl.RequestBatchUpdateEventHandler; import org.folio.service.impl.RequestEventHandler; import org.folio.service.impl.UserEventHandler; @@ -15,6 +17,7 @@ import org.folio.spring.service.SystemUserScopedExecutionService; 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 org.springframework.messaging.MessageHeaders; @@ -24,6 +27,8 @@ class KafkaEventListenerTest { @Mock RequestEventHandler requestEventHandler; @Mock + LoanEventHandler loanEventHandler; + @Mock RequestBatchUpdateEventHandler requestBatchEventHandler; @Mock SystemUserScopedExecutionService systemUserScopedExecutionService; @@ -31,14 +36,15 @@ class KafkaEventListenerTest { UserGroupEventHandler userGroupEventHandler; @Mock UserEventHandler userEventHandler; + @Mock + ConsortiaService consortiaService; + @InjectMocks + KafkaEventListener kafkaEventListener; @Test void shouldHandleExceptionInEventHandler() { doThrow(new NullPointerException("NPE")).when(systemUserScopedExecutionService) .executeAsyncSystemUserScoped(any(), any()); - KafkaEventListener kafkaEventListener = new KafkaEventListener(requestEventHandler, - requestBatchEventHandler, systemUserScopedExecutionService, userGroupEventHandler, - userEventHandler); kafkaEventListener.handleRequestEvent("{}", new MessageHeaders(Map.of(TENANT, "default".getBytes()))); diff --git a/src/test/java/org/folio/service/DcbServiceTest.java b/src/test/java/org/folio/service/DcbServiceTest.java new file mode 100644 index 00000000..3b7eb402 --- /dev/null +++ b/src/test/java/org/folio/service/DcbServiceTest.java @@ -0,0 +1,103 @@ +package org.folio.service; + +import static java.util.UUID.randomUUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.concurrent.Callable; + +import org.folio.client.feign.DcbTransactionClient; +import org.folio.domain.dto.TransactionStatus; +import org.folio.domain.dto.TransactionStatusResponse; +import org.folio.service.impl.DcbServiceImpl; +import org.folio.spring.service.SystemUserScopedExecutionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DcbServiceTest { + + @Mock + private DcbTransactionClient dcbTransactionClient; + @Mock + private SystemUserScopedExecutionService executionService; + @InjectMocks + private DcbServiceImpl dcbService; + + @BeforeEach + public void setup() { + // Bypass the use of system user and return the result of Callable immediately + when(executionService.executeSystemUserScoped(any(String.class), any(Callable.class))) + .thenAnswer(invocation -> invocation.getArgument(1, Callable.class).call()); + } + + @ParameterizedTest + @CsvSource(value = { + "PICKUP, CREATED, OPEN, true", + "PICKUP, OPEN, AWAITING_PICKUP, false", + "PICKUP, AWAITING_PICKUP, ITEM_CHECKED_OUT, false", + "PICKUP, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, false", + "PICKUP, ITEM_CHECKED_IN, CLOSED, true", + "PICKUP, OPEN, CANCELLED, true", + + "BORROWING-PICKUP, CREATED, OPEN, true", + "BORROWING-PICKUP, OPEN, AWAITING_PICKUP, false", + "BORROWING-PICKUP, AWAITING_PICKUP, ITEM_CHECKED_OUT, false", + "BORROWING-PICKUP, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, false", + "BORROWING-PICKUP, ITEM_CHECKED_IN, CLOSED, true", + "BORROWING-PICKUP, OPEN, CANCELLED, true", + + "BORROWER, CREATED, OPEN, true", + "BORROWER, OPEN, AWAITING_PICKUP, true", + "BORROWER, AWAITING_PICKUP, ITEM_CHECKED_OUT, true", + "BORROWER, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, true", + "BORROWER, ITEM_CHECKED_IN, CLOSED, true", + "BORROWER, OPEN, CANCELLED, true", + + "LENDER, CREATED, OPEN, true", + "LENDER, OPEN, AWAITING_PICKUP, true", + "LENDER, AWAITING_PICKUP, ITEM_CHECKED_OUT, true", + "LENDER, ITEM_CHECKED_OUT, ITEM_CHECKED_IN, true", + "LENDER, ITEM_CHECKED_IN, CLOSED, false", + "LENDER, OPEN, CANCELLED, true", + }) + void updateTransactionStatusesUpdatesAllTransactions(String role, String oldStatus, + String newStatus, boolean transactionUpdateIsExpected) { + + String transactionId = randomUUID().toString(); + TransactionStatus newTransactionStatus = new TransactionStatus().status( + TransactionStatus.StatusEnum.fromValue(newStatus)); + + TransactionStatusResponse mockGetStatusResponse = buildTransactionStatusResponse(role, oldStatus); + TransactionStatusResponse mockUpdateStatusResponse = buildTransactionStatusResponse(role, newStatus); + + when(dcbTransactionClient.getDcbTransactionStatus(transactionId)) + .thenReturn(mockGetStatusResponse); + + if (transactionUpdateIsExpected) { + when(dcbTransactionClient.changeDcbTransactionStatus(transactionId, newTransactionStatus)) + .thenReturn(mockUpdateStatusResponse); + } + + dcbService.updateTransactionStatus(UUID.fromString(transactionId), + newTransactionStatus.getStatus(), "test_tenant"); + + verify(dcbTransactionClient, times(transactionUpdateIsExpected ? 1 : 0)) + .changeDcbTransactionStatus(transactionId, newTransactionStatus); + } + + private static TransactionStatusResponse buildTransactionStatusResponse(String role, String status) { + return new TransactionStatusResponse() + .role(TransactionStatusResponse.RoleEnum.fromValue(role)) + .status(TransactionStatusResponse.StatusEnum.fromValue(status)); + } + +} \ No newline at end of file diff --git a/src/test/java/org/folio/service/LoanEventHandlerTest.java b/src/test/java/org/folio/service/LoanEventHandlerTest.java new file mode 100644 index 00000000..294e4adc --- /dev/null +++ b/src/test/java/org/folio/service/LoanEventHandlerTest.java @@ -0,0 +1,199 @@ +package org.folio.service; + +import static java.util.Collections.emptyList; +import static java.util.UUID.randomUUID; +import static org.folio.support.KafkaEvent.EventType.UPDATED; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; + +import org.folio.domain.dto.Loan; +import org.folio.domain.dto.TransactionStatus; +import org.folio.domain.dto.TransactionStatusResponse; +import org.folio.domain.entity.EcsTlrEntity; +import org.folio.repository.EcsTlrRepository; +import org.folio.service.impl.LoanEventHandler; +import org.folio.support.KafkaEvent; +import org.folio.support.KafkaEvent.EventType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoanEventHandlerTest { + + private static final EnumSet SUPPORTED_EVENT_TYPES = EnumSet.of(UPDATED); + + @Mock + private DcbService dcbService; + @Mock + private EcsTlrRepository ecsTlrRepository; + @InjectMocks + private LoanEventHandler loanEventHandler; + + @ParameterizedTest + @EnumSource(EventType.class) + void eventsOfUnsupportedTypesAreIgnored(EventType eventType) { + if (!SUPPORTED_EVENT_TYPES.contains(eventType)) { + loanEventHandler.handle(new KafkaEvent<>(null, null, eventType, 0L, null, null)); + verifyNoInteractions(ecsTlrRepository, dcbService); + } + } + + @Test + void updateEventForLoanWithUnsupportedActionInIgnored() { + Loan loan = new Loan().action("random_action"); + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + verifyNoInteractions(ecsTlrRepository, dcbService); + } + + @Test + void checkInEventIsIgnoredWhenEcsTlrForUpdatedLoanIsNotFound() { + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .id(randomUUID().toString()) + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + when(ecsTlrRepository.findByItemId(itemId)) + .thenReturn(emptyList()); + + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemId(itemId); + verifyNoInteractions(dcbService); + } + + @Test + void checkInEventIsIgnoredWhenEcsTlrDoesNotContainsNoTransactionIds() { + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .id(randomUUID().toString()) + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + when(ecsTlrRepository.findByItemId(itemId)) + .thenReturn(List.of(new EcsTlrEntity())); + + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemId(itemId); + verifyNoInteractions(dcbService); + } + + @Test + void checkInEventIsIgnoredWhenEventTenantDoesNotMatchEcsRequestTransactionTenants() { + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .id(randomUUID().toString()) + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + EcsTlrEntity ecsTlr = new EcsTlrEntity(); + ecsTlr.setPrimaryRequestTenantId("borrowing_tenant"); + ecsTlr.setSecondaryRequestTenantId("lending_tenant"); + ecsTlr.setPrimaryRequestDcbTransactionId(randomUUID()); + ecsTlr.setSecondaryRequestDcbTransactionId(randomUUID()); + + when(ecsTlrRepository.findByItemId(itemId)) + .thenReturn(List.of(ecsTlr)); + + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), "test_tenant", UPDATED, + 0L, new KafkaEvent.EventData<>(loan, loan), "test_tenant"); + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemId(itemId); + verifyNoInteractions(dcbService); + } + + @ParameterizedTest + @CsvSource(value = { + "BORROWING-PICKUP, ITEM_CHECKED_OUT, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + "BORROWING-PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + "PICKUP, ITEM_CHECKED_OUT, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + "PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_OUT, borrowing_tenant, ITEM_CHECKED_IN", + + "BORROWING-PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_IN, lending_tenant, CLOSED", + "BORROWING-PICKUP, ITEM_CHECKED_IN, LENDER, CLOSED, lending_tenant, CLOSED", + "PICKUP, ITEM_CHECKED_IN, LENDER, ITEM_CHECKED_IN, lending_tenant, CLOSED", + "PICKUP, ITEM_CHECKED_IN, LENDER, CLOSED, lending_tenant, CLOSED" + }) + void checkInEventIsHandled(String primaryTransactionRole, String primaryTransactionStatus, + String secondaryTransactionRole, String secondaryTransactionStatus, String eventTenant, + String expectedNewTransactionStatus) { + + String primaryRequestTenant = "borrowing_tenant"; + String secondaryRequestTenant = "lending_tenant"; + UUID primaryTransactionId = randomUUID(); + UUID secondaryTransactionId = randomUUID(); + UUID itemId = randomUUID(); + UUID userId = randomUUID(); + Loan loan = new Loan() + .action("checkedin") + .itemId(itemId.toString()) + .userId(userId.toString()); + + EcsTlrEntity mockEcsTlr = new EcsTlrEntity(); + mockEcsTlr.setId(randomUUID()); + mockEcsTlr.setPrimaryRequestTenantId(primaryRequestTenant); + mockEcsTlr.setSecondaryRequestTenantId(secondaryRequestTenant); + mockEcsTlr.setPrimaryRequestDcbTransactionId(primaryTransactionId); + mockEcsTlr.setSecondaryRequestDcbTransactionId(secondaryTransactionId); + + when(ecsTlrRepository.findByItemId(itemId)) + .thenReturn(List.of(mockEcsTlr)); + + TransactionStatusResponse mockPrimaryTransactionResponse = buildTransactionStatusResponse( + primaryTransactionRole, primaryTransactionStatus); + TransactionStatusResponse mockSecondaryTransactionResponse = buildTransactionStatusResponse( + secondaryTransactionRole, secondaryTransactionStatus); + + when(dcbService.getTransactionStatus(primaryTransactionId, primaryRequestTenant)) + .thenReturn(mockPrimaryTransactionResponse); + when(dcbService.getTransactionStatus(secondaryTransactionId, secondaryRequestTenant)) + .thenReturn(mockSecondaryTransactionResponse); + + TransactionStatus.StatusEnum expectedNewStatus = TransactionStatus.StatusEnum.fromValue( + expectedNewTransactionStatus); + doNothing().when(dcbService).updateTransactionStatuses(expectedNewStatus, mockEcsTlr); + + KafkaEvent.EventData eventData = new KafkaEvent.EventData<>(loan, loan); + KafkaEvent event = new KafkaEvent<>(randomUUID().toString(), eventTenant, + UPDATED, 0L, eventData, eventTenant); + + loanEventHandler.handle(event); + + verify(ecsTlrRepository).findByItemId(itemId); + verify(dcbService).getTransactionStatus(primaryTransactionId, primaryRequestTenant); + verify(dcbService).getTransactionStatus(secondaryTransactionId, secondaryRequestTenant); + verify(dcbService).updateTransactionStatuses(expectedNewStatus, mockEcsTlr); + } + + private static TransactionStatusResponse buildTransactionStatusResponse(String role, String status) { + return new TransactionStatusResponse() + .role(TransactionStatusResponse.RoleEnum.fromValue(role)) + .status(TransactionStatusResponse.StatusEnum.fromValue(status)); + } +} diff --git a/src/test/java/org/folio/service/RequestBatchUpdateEventHandlerTest.java b/src/test/java/org/folio/service/RequestBatchUpdateEventHandlerTest.java index 8425f55c..2de1dcd0 100644 --- a/src/test/java/org/folio/service/RequestBatchUpdateEventHandlerTest.java +++ b/src/test/java/org/folio/service/RequestBatchUpdateEventHandlerTest.java @@ -116,7 +116,7 @@ void shouldReorderTwoSecondaryRequestsWhenPrimaryRequestsReordered() { null, new RequestsBatchUpdate() .instanceId(instanceId) .requestLevel(RequestsBatchUpdate.RequestLevelEnum.TITLE))), - getMessageHeaders(CENTRAL_TENANT_ID, CENTRAL_TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(requestService, times(1)).reorderRequestsQueueForInstance( instanceId, firstTenant, reorderQueue); @@ -193,7 +193,7 @@ void shouldReorderThreeSecondaryRequestsWhenPrimaryRequestsReordered() { null, new RequestsBatchUpdate() .instanceId(instanceId) .requestLevel(RequestsBatchUpdate.RequestLevelEnum.TITLE))), - getMessageHeaders(CENTRAL_TENANT_ID, CENTRAL_TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(requestService, times(1)).reorderRequestsQueueForInstance( instanceId, firstTenant, reorderQueue); @@ -250,7 +250,7 @@ void shouldNotReorderSecondaryRequestsWhenPrimaryRequestsOrderIsUnchanged() { null, new RequestsBatchUpdate() .instanceId(instanceId) .requestLevel(RequestsBatchUpdate.RequestLevelEnum.TITLE))), - getMessageHeaders(CENTRAL_TENANT_ID, CENTRAL_TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(requestService, times(0)).reorderRequestsQueueForInstance( eq(instanceId), eq(firstTenant), any()); @@ -308,7 +308,7 @@ void shouldNotReorderSecondaryRequestsWhenPrimaryRequestsAreNullOrEmtpy( null, new RequestsBatchUpdate() .instanceId(instanceId) .requestLevel(RequestsBatchUpdate.RequestLevelEnum.TITLE))), - getMessageHeaders(CENTRAL_TENANT_ID, CENTRAL_TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(requestService, times(0)).reorderRequestsQueueForInstance( eq(instanceId), eq(firstTenant), any()); @@ -377,7 +377,7 @@ null, new RequestsBatchUpdate() .instanceId(instanceId) .itemId(itemId) .requestLevel(RequestsBatchUpdate.RequestLevelEnum.ITEM))), - getMessageHeaders(CENTRAL_TENANT_ID, CENTRAL_TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(requestService, times(1)).reorderRequestsQueueForItem( itemId, firstTenant, reorderQueue); diff --git a/src/test/java/org/folio/service/RequestEventHandlerTest.java b/src/test/java/org/folio/service/RequestEventHandlerTest.java index 70c2615b..a1172aee 100644 --- a/src/test/java/org/folio/service/RequestEventHandlerTest.java +++ b/src/test/java/org/folio/service/RequestEventHandlerTest.java @@ -8,7 +8,6 @@ import static org.mockito.Mockito.when; import java.util.Optional; -import java.util.UUID; import org.folio.api.BaseIT; import org.folio.listener.kafka.KafkaEventListener; @@ -35,8 +34,7 @@ class RequestEventHandlerTest extends BaseIT { void handleRequestUpdateTest() { when(ecsTlrRepository.findBySecondaryRequestId(any())).thenReturn(Optional.of(getEcsTlrEntity())); doNothing().when(dcbService).createLendingTransaction(any()); - eventListener.handleRequestEvent(REQUEST_UPDATE_EVENT_SAMPLE, getMessageHeaders( - TENANT_ID_CONSORTIUM, UUID.randomUUID().toString())); + eventListener.handleRequestEvent(REQUEST_UPDATE_EVENT_SAMPLE, buildKafkaHeaders(TENANT_ID_CONSORTIUM)); verify(ecsTlrRepository).findBySecondaryRequestId(any()); } } diff --git a/src/test/java/org/folio/service/UserEventHandlerTest.java b/src/test/java/org/folio/service/UserEventHandlerTest.java index 94569125..7e7f4035 100644 --- a/src/test/java/org/folio/service/UserEventHandlerTest.java +++ b/src/test/java/org/folio/service/UserEventHandlerTest.java @@ -23,6 +23,7 @@ void handleUserUpdatingEventShouldUpdateUserForAllDataTenants() { when(userTenantsService.findFirstUserTenant()).thenReturn(mockUserTenant()); when(consortiaService.getAllConsortiumTenants(anyString())).thenReturn(mockTenantCollection()); when(userService.update(any(User.class))).thenReturn(new User()); + when(consortiaService.getCentralTenantId()).thenReturn(CENTRAL_TENANT_ID); doAnswer(invocation -> { ((Runnable) invocation.getArguments()[1]).run(); @@ -30,8 +31,7 @@ void handleUserUpdatingEventShouldUpdateUserForAllDataTenants() { }).when(systemUserScopedExecutionService).executeAsyncSystemUserScoped(anyString(), any(Runnable.class)); - eventListener.handleUserEvent(USER_UPDATING_EVENT_SAMPLE, - getMessageHeaders(TENANT, TENANT_ID)); + eventListener.handleUserEvent(USER_UPDATING_EVENT_SAMPLE, buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(systemUserScopedExecutionService, times(3)) .executeAsyncSystemUserScoped(anyString(), any(Runnable.class)); diff --git a/src/test/java/org/folio/service/UserGroupEventHandlerTest.java b/src/test/java/org/folio/service/UserGroupEventHandlerTest.java index 6b92c7f5..5b6c227f 100644 --- a/src/test/java/org/folio/service/UserGroupEventHandlerTest.java +++ b/src/test/java/org/folio/service/UserGroupEventHandlerTest.java @@ -30,6 +30,7 @@ void handleUserGroupCreatingEventShouldCreateUserGroupForAllDataTenants() { when(userTenantsService.findFirstUserTenant()).thenReturn(mockUserTenant()); when(consortiaService.getAllConsortiumTenants(anyString())).thenReturn(mockTenantCollection()); when(userGroupService.create(any(UserGroup.class))).thenReturn(new UserGroup()); + when(consortiaService.getCentralTenantId()).thenReturn(CENTRAL_TENANT_ID); doAnswer(invocation -> { ((Runnable) invocation.getArguments()[1]).run(); @@ -38,7 +39,7 @@ void handleUserGroupCreatingEventShouldCreateUserGroupForAllDataTenants() { any(Runnable.class)); eventListener.handleUserGroupEvent(USER_GROUP_CREATING_EVENT_SAMPLE, - getMessageHeaders(TENANT, TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(systemUserScopedExecutionService, times(3)).executeAsyncSystemUserScoped(anyString(), any(Runnable.class)); @@ -50,6 +51,7 @@ void handleUserGroupUpdatingEventShouldUpdateUserGroupForAllDataTenants() { when(userTenantsService.findFirstUserTenant()).thenReturn(mockUserTenant()); when(consortiaService.getAllConsortiumTenants(anyString())).thenReturn(mockTenantCollection()); when(userGroupService.update(any(UserGroup.class))).thenReturn(new UserGroup()); + when(consortiaService.getCentralTenantId()).thenReturn(CENTRAL_TENANT_ID); doAnswer(invocation -> { ((Runnable) invocation.getArguments()[1]).run(); @@ -58,7 +60,7 @@ void handleUserGroupUpdatingEventShouldUpdateUserGroupForAllDataTenants() { any(Runnable.class)); eventListener.handleUserGroupEvent(USER_GROUP_UPDATING_EVENT_SAMPLE, - getMessageHeaders(TENANT, TENANT_ID)); + buildKafkaHeaders(CENTRAL_TENANT_ID)); verify(systemUserScopedExecutionService, times(3)) .executeAsyncSystemUserScoped(anyString(), any(Runnable.class)); diff --git a/src/test/resources/mappings/consortiaConfiguration.json b/src/test/resources/mappings/consortiaConfiguration.json new file mode 100644 index 00000000..b978e6df --- /dev/null +++ b/src/test/resources/mappings/consortiaConfiguration.json @@ -0,0 +1,20 @@ +{ + "mappings": [ + { + "request": { + "method": "GET", + "url": "/consortia-configuration" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "id": "0bc8835b-1233-48ba-bc75-979cb04dc06e", + "centralTenantId": "consortium" + } + } + } + ] +}