diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 5c4a512b..4114dfa2 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -18,7 +18,11 @@ "permissionsRequired": ["tlr.ecs-tlr.post"], "modulePermissions": [ "circulation.requests.instances.item.post", - "search.instances.collection.get" + "circulation.requests.item.post", + "search.instances.collection.get", + "users.item.get", + "users.collection.get", + "users.item.post" ] }, { diff --git a/src/main/java/org/folio/client/feign/CirculationClient.java b/src/main/java/org/folio/client/feign/CirculationClient.java index c1faab16..cf4e111f 100644 --- a/src/main/java/org/folio/client/feign/CirculationClient.java +++ b/src/main/java/org/folio/client/feign/CirculationClient.java @@ -5,9 +5,12 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; -@FeignClient(name = "circulation", url = "${folio.okapi-url}", configuration = FeignClientConfiguration.class) +@FeignClient(name = "circulation", url = "circulation", configuration = FeignClientConfiguration.class) public interface CirculationClient { - @PostMapping("/circulation/requests/instances") + @PostMapping("/requests/instances") Request createInstanceRequest(Request request); + + @PostMapping("/requests") + Request createRequest(Request request); } diff --git a/src/main/java/org/folio/client/feign/SearchClient.java b/src/main/java/org/folio/client/feign/SearchClient.java index 21540b06..b968affa 100644 --- a/src/main/java/org/folio/client/feign/SearchClient.java +++ b/src/main/java/org/folio/client/feign/SearchClient.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; -@FeignClient(name = "search", configuration = FeignClientConfiguration.class) +@FeignClient(name = "search", url = "search", configuration = FeignClientConfiguration.class) public interface SearchClient { @GetMapping("/instances") diff --git a/src/main/java/org/folio/client/feign/UsersClient.java b/src/main/java/org/folio/client/feign/UsersClient.java new file mode 100644 index 00000000..ecf802f0 --- /dev/null +++ b/src/main/java/org/folio/client/feign/UsersClient.java @@ -0,0 +1,20 @@ +package org.folio.client.feign; + +import org.folio.domain.dto.User; +import org.folio.spring.config.FeignClientConfiguration; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient(name = "users", url = "users", configuration = FeignClientConfiguration.class) +public interface UsersClient { + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + User postUser(@RequestBody User user); + + @GetMapping("/{userId}") + User getUser(@PathVariable String userId); +} diff --git a/src/main/java/org/folio/domain/RequestWrapper.java b/src/main/java/org/folio/domain/RequestWrapper.java new file mode 100644 index 00000000..6b5b0e12 --- /dev/null +++ b/src/main/java/org/folio/domain/RequestWrapper.java @@ -0,0 +1,6 @@ +package org.folio.domain; + +import org.folio.domain.dto.Request; + +public record RequestWrapper(Request request, String tenantId) { +} diff --git a/src/main/java/org/folio/domain/dto/UserType.java b/src/main/java/org/folio/domain/dto/UserType.java new file mode 100644 index 00000000..aa687d46 --- /dev/null +++ b/src/main/java/org/folio/domain/dto/UserType.java @@ -0,0 +1,12 @@ +package org.folio.domain.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum UserType { + SHADOW("shadow"); + + private final String value; +} diff --git a/src/main/java/org/folio/domain/strategy/TenantPickingStrategy.java b/src/main/java/org/folio/domain/strategy/TenantPickingStrategy.java deleted file mode 100644 index 97ad0ec5..00000000 --- a/src/main/java/org/folio/domain/strategy/TenantPickingStrategy.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.folio.domain.strategy; - -import java.util.List; - -public interface TenantPickingStrategy { - List findTenants(String instanceId); -} diff --git a/src/main/java/org/folio/service/RequestService.java b/src/main/java/org/folio/service/RequestService.java new file mode 100644 index 00000000..f770838c --- /dev/null +++ b/src/main/java/org/folio/service/RequestService.java @@ -0,0 +1,13 @@ +package org.folio.service; + +import java.util.Collection; + +import org.folio.domain.RequestWrapper; +import org.folio.domain.dto.Request; + +public interface RequestService { + RequestWrapper createPrimaryRequest(Request request, String borrowingTenantId); + + RequestWrapper createSecondaryRequest(Request request, String borrowingTenantId, + Collection lendingTenantIds); +} diff --git a/src/main/java/org/folio/service/TenantService.java b/src/main/java/org/folio/service/TenantService.java new file mode 100644 index 00000000..aae3599d --- /dev/null +++ b/src/main/java/org/folio/service/TenantService.java @@ -0,0 +1,12 @@ +package org.folio.service; + +import java.util.List; +import java.util.Optional; + +import org.folio.domain.dto.EcsTlr; + +public interface TenantService { + Optional getBorrowingTenant(EcsTlr ecsTlr); + + List getLendingTenants(EcsTlr ecsTlr); +} diff --git a/src/main/java/org/folio/service/UserService.java b/src/main/java/org/folio/service/UserService.java new file mode 100644 index 00000000..a2bc6103 --- /dev/null +++ b/src/main/java/org/folio/service/UserService.java @@ -0,0 +1,9 @@ +package org.folio.service; + +import org.folio.domain.dto.User; + +public interface UserService { + User createShadowUser(User realUser, String tenantId); + + User findUser(String userId, String tenantId); +} diff --git a/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java b/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java index d3abcace..0e58c564 100644 --- a/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java +++ b/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java @@ -1,21 +1,20 @@ package org.folio.service.impl; -import static java.lang.String.format; - +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; -import org.folio.client.feign.CirculationClient; +import org.folio.domain.RequestWrapper; import org.folio.domain.dto.EcsTlr; import org.folio.domain.dto.Request; +import org.folio.domain.entity.EcsTlrEntity; import org.folio.domain.mapper.EcsTlrMapper; -import org.folio.domain.strategy.TenantPickingStrategy; -import org.folio.exception.RequestCreatingException; import org.folio.exception.TenantPickingException; import org.folio.repository.EcsTlrRepository; import org.folio.service.EcsTlrService; -import org.folio.service.TenantScopedExecutionService; +import org.folio.service.RequestService; +import org.folio.service.TenantService; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -28,9 +27,8 @@ public class EcsTlrServiceImpl implements EcsTlrService { private final EcsTlrRepository ecsTlrRepository; private final EcsTlrMapper requestsMapper; - private final CirculationClient circulationClient; - private final TenantScopedExecutionService tenantScopedExecutionService; - private final TenantPickingStrategy tenantPickingStrategy; + private final TenantService tenantService; + private final RequestService requestService; @Override public Optional get(UUID id) { @@ -42,24 +40,18 @@ public Optional get(UUID id) { @Override public EcsTlr create(EcsTlr ecsTlr) { - log.debug("create:: parameters ecsTlr: {}", () -> ecsTlr); - final String instanceId = ecsTlr.getInstanceId(); - - List tenantIds = tenantPickingStrategy.findTenants(instanceId); - if (tenantIds.isEmpty()) { - log.error("create:: failed to find tenants for instance: {}", instanceId); - throw new TenantPickingException("Failed to find tenants for instance " + instanceId); - } - for (String tenantId : tenantIds) { - try { - return createRequest(ecsTlr, tenantId); - } catch (Exception e) { - log.error("create:: failed to create a request for tenant {}: {}", tenantId, e.getMessage()); - log.debug("create:: ", e); - } - } - throw new RequestCreatingException(format( - "Failed to create a request for instanceId %s in tenants %s", instanceId, tenantIds)); + log.info("create:: creating ECS TLR {} for instance {} and requester {}", ecsTlr.getId(), + ecsTlr.getInstanceId(), ecsTlr.getRequesterId()); + + String borrowingTenantId = getBorrowingTenant(ecsTlr); + Collection lendingTenantIds = getLendingTenants(ecsTlr); + RequestWrapper secondaryRequest = requestService.createSecondaryRequest( + requestsMapper.mapDtoToRequest(ecsTlr), borrowingTenantId, lendingTenantIds); + RequestWrapper primaryRequest = requestService.createPrimaryRequest( + buildPrimaryRequest(secondaryRequest.request()), borrowingTenantId); + updateEcsTlr(ecsTlr, primaryRequest, secondaryRequest); + + return save(ecsTlr); } @Override @@ -84,23 +76,61 @@ public boolean delete(UUID requestId) { return false; } - private EcsTlr createRequest(EcsTlr ecsTlr, String tenantId) { - log.info("createRequest:: creating request for ECS TLR {} in tenant {}", ecsTlr.getId(), tenantId); + private String getBorrowingTenant(EcsTlr ecsTlr) { + log.info("getBorrowingTenant:: getting borrowing tenant"); + final String borrowingTenantId = tenantService.getBorrowingTenant(ecsTlr) + .orElseThrow(() -> new TenantPickingException("Failed to get borrowing tenant")); + log.info("getBorrowingTenant:: borrowing tenant: {}", borrowingTenantId); + + return borrowingTenantId; + } + + private Collection getLendingTenants(EcsTlr ecsTlr) { + final String instanceId = ecsTlr.getInstanceId(); + log.info("getLendingTenants:: looking for lending tenants for instance {}", instanceId); + List tenantIds = tenantService.getLendingTenants(ecsTlr); + if (tenantIds.isEmpty()) { + log.error("getLendingTenants:: failed to find lending tenants for instance: {}", instanceId); + throw new TenantPickingException("Failed to find lending tenants for instance " + instanceId); + } - Request mappedRequest = requestsMapper.mapDtoToRequest(ecsTlr); - Request createdRequest = tenantScopedExecutionService.execute(tenantId, - () -> circulationClient.createInstanceRequest(mappedRequest)); + log.info("getLendingTenants:: lending tenants found: {}", tenantIds); + return tenantIds; + } - log.info("createRequest:: request created: {}", createdRequest.getId()); - log.debug("createRequest:: request: {}", () -> createdRequest); + private EcsTlr save(EcsTlr ecsTlr) { + log.info("save:: saving ECS TLR {}", ecsTlr.getId()); + EcsTlrEntity updatedEcsTlr = ecsTlrRepository.save(requestsMapper.mapDtoToEntity(ecsTlr)); + log.info("save:: saved ECS TLR {}", ecsTlr.getId()); + log.debug("save:: ECS TLR: {}", () -> ecsTlr); - ecsTlr.secondaryRequestTenantId(tenantId) - .secondaryRequestId(createdRequest.getId()) - .itemId(createdRequest.getItemId()); + return requestsMapper.mapEntityToDto(updatedEcsTlr); + } - log.debug("createRequest:: updating ECS TLR: {}", () -> ecsTlr); + private static Request buildPrimaryRequest(Request secondaryRequest) { + return new Request() + .id(secondaryRequest.getId()) + .instanceId(secondaryRequest.getInstanceId()) + .requesterId(secondaryRequest.getRequesterId()) + .requestDate(secondaryRequest.getRequestDate()) + .requestLevel(Request.RequestLevelEnum.TITLE) + .requestType(Request.RequestTypeEnum.HOLD) + .fulfillmentPreference(Request.FulfillmentPreferenceEnum.HOLD_SHELF) + .pickupServicePointId(secondaryRequest.getPickupServicePointId()); + } + + private static void updateEcsTlr(EcsTlr ecsTlr, RequestWrapper primaryRequest, + RequestWrapper secondaryRequest) { - return requestsMapper.mapEntityToDto(ecsTlrRepository.save( - requestsMapper.mapDtoToEntity(ecsTlr))); + log.info("updateEcsTlr:: updating ECS TLR in memory"); + ecsTlr.primaryRequestTenantId(primaryRequest.tenantId()) + .primaryRequestId(primaryRequest.request().getId()) + .secondaryRequestTenantId(secondaryRequest.tenantId()) + .secondaryRequestId(secondaryRequest.request().getId()) + .itemId(secondaryRequest.request().getItemId()); + + log.info("updateEcsTlr:: ECS TLR updated in memory"); + log.debug("updateEcsTlr:: ECS TLR: {}", () -> ecsTlr); } + } diff --git a/src/main/java/org/folio/service/impl/RequestServiceImpl.java b/src/main/java/org/folio/service/impl/RequestServiceImpl.java new file mode 100644 index 00000000..1ddc1073 --- /dev/null +++ b/src/main/java/org/folio/service/impl/RequestServiceImpl.java @@ -0,0 +1,87 @@ +package org.folio.service.impl; + +import static java.lang.String.format; + +import java.util.Collection; + +import org.folio.client.feign.CirculationClient; +import org.folio.domain.RequestWrapper; +import org.folio.domain.dto.Request; +import org.folio.domain.dto.User; +import org.folio.exception.RequestCreatingException; +import org.folio.service.RequestService; +import org.folio.service.TenantScopedExecutionService; +import org.folio.service.UserService; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class RequestServiceImpl implements RequestService { + private final TenantScopedExecutionService tenantScopedExecutionService; + private final CirculationClient circulationClient; + private final UserService userService; + + @Override + public RequestWrapper createPrimaryRequest(Request request, String borrowingTenantId) { + final String requestId = request.getId(); + log.info("createPrimaryRequest:: creating primary request {} in borrowing tenant {}", + requestId, borrowingTenantId); + Request primaryRequest = tenantScopedExecutionService.execute(borrowingTenantId, + () -> circulationClient.createRequest(request)); + log.info("createPrimaryRequest:: primary request {} created in borrowing tenant {}", + requestId, borrowingTenantId); + log.debug("createPrimaryRequest:: primary request: {}", () -> primaryRequest); + + return new RequestWrapper(primaryRequest, borrowingTenantId); + } + + @Override + public RequestWrapper createSecondaryRequest(Request request, String borrowingTenantId, + Collection lendingTenantIds) { + + log.info("createSecondaryRequest:: attempting to create secondary request in one of potential " + + "lending tenants: {}", lendingTenantIds); + final String requesterId = request.getRequesterId(); + + log.info("createSecondaryRequest:: looking for requester {} in borrowing tenant ({})", + requesterId, borrowingTenantId); + User realRequester = userService.findUser(requesterId, borrowingTenantId); + + for (String lendingTenantId : lendingTenantIds) { + try { + log.info("createSecondaryRequest:: attempting to create shadow requester {} in lending tenant {}", + requesterId, lendingTenantId); + userService.createShadowUser(realRequester, lendingTenantId); + return createSecondaryRequest(request, lendingTenantId); + } catch (Exception e) { + log.error("createSecondaryRequest:: failed to create secondary request in lending tenant {}: {}", + lendingTenantId, e.getMessage()); + log.debug("createSecondaryRequest:: ", e); + } + } + + String errorMessage = format( + "Failed to create secondary request for instance %s in all potential lending tenants: %s", + request.getInstanceId(), lendingTenantIds); + log.error("createSecondaryRequest:: {}", errorMessage); + throw new RequestCreatingException(errorMessage); + } + + private RequestWrapper createSecondaryRequest(Request request, String lendingTenantId) { + final String requestId = request.getId(); + log.info("createSecondaryRequest:: creating secondary request {} in lending tenant {}", + requestId, lendingTenantId); + Request secondaryRequest = tenantScopedExecutionService.execute(lendingTenantId, + () -> circulationClient.createInstanceRequest(request)); + log.info("createSecondaryRequest:: secondary request {} created in lending tenant {}", + requestId, lendingTenantId); + log.debug("createSecondaryRequest:: secondary request: {}", () -> secondaryRequest); + + return new RequestWrapper(secondaryRequest, lendingTenantId); + } + +} diff --git a/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java index 5d871d98..3f1bd3f9 100644 --- a/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java +++ b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java @@ -33,7 +33,7 @@ public T execute(String tenantId, Callable action) { try (var x = new FolioExecutionContextSetter(moduleMetadata, headers)) { return action.call(); } catch (Exception e) { - log.error("execute:: tenantId: {}", tenantId, e); + log.error("execute:: execution failed for tenant {}: {}", tenantId, e.getMessage()); throw new TenantScopedExecutionException(e, tenantId); } } diff --git a/src/main/java/org/folio/domain/strategy/ItemStatusBasedTenantPickingStrategy.java b/src/main/java/org/folio/service/impl/TenantServiceImpl.java similarity index 74% rename from src/main/java/org/folio/domain/strategy/ItemStatusBasedTenantPickingStrategy.java rename to src/main/java/org/folio/service/impl/TenantServiceImpl.java index 3e869d7c..bbb50c82 100644 --- a/src/main/java/org/folio/domain/strategy/ItemStatusBasedTenantPickingStrategy.java +++ b/src/main/java/org/folio/service/impl/TenantServiceImpl.java @@ -1,4 +1,4 @@ -package org.folio.domain.strategy; +package org.folio.service.impl; import static com.google.common.base.Predicates.alwaysTrue; import static com.google.common.base.Predicates.notNull; @@ -19,14 +19,18 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Set; import java.util.function.Predicate; import org.folio.client.feign.SearchClient; +import org.folio.domain.dto.EcsTlr; import org.folio.domain.dto.Instance; import org.folio.domain.dto.Item; import org.folio.domain.dto.ItemStatus; import org.folio.domain.dto.ItemStatusEnum; +import org.folio.service.TenantService; +import org.folio.util.HttpUtils; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Component; @@ -36,17 +40,23 @@ @Component @RequiredArgsConstructor @Log4j2 -public class ItemStatusBasedTenantPickingStrategy implements TenantPickingStrategy { +public class TenantServiceImpl implements TenantService { private final SearchClient searchClient; @Override - public List findTenants(String instanceId) { - log.info("findTenants:: find tenants for a TLR for instance {}", instanceId); + public Optional getBorrowingTenant(EcsTlr ecsTlr) { + log.info("getBorrowingTenant:: getting borrowing tenant"); + return HttpUtils.getTenantFromToken(); + } + @Override + public List getLendingTenants(EcsTlr ecsTlr) { + final String instanceId = ecsTlr.getInstanceId(); + log.info("getLendingTenants:: looking for potential lending tenants for instance {}", instanceId); var itemStatusOccurrencesByTenant = getItemStatusOccurrencesByTenant(instanceId); - log.info("findTenants:: item status occurrences by tenant: {}", itemStatusOccurrencesByTenant); + log.info("getLendingTenants:: item status occurrences by tenant: {}", itemStatusOccurrencesByTenant); - List sortedTenantIds = itemStatusOccurrencesByTenant.entrySet() + List lendingTenantIds = itemStatusOccurrencesByTenant.entrySet() .stream() .sorted(compareByItemCount(AVAILABLE) .thenComparing(compareByItemCount(CHECKED_OUT, IN_TRANSIT)) @@ -54,13 +64,13 @@ public List findTenants(String instanceId) { .map(Entry::getKey) .toList(); - if (sortedTenantIds.isEmpty()) { - log.warn("findTenants:: failed to find tenants for instance {}", instanceId); + if (lendingTenantIds.isEmpty()) { + log.warn("getLendingTenants:: failed to find lending tenants for instance {}", instanceId); } else { - log.info("findTenants:: tenants for instance {} found: {}", instanceId, sortedTenantIds); + log.info("getLendingTenants:: found tenants for instance {}: {}", instanceId, lendingTenantIds); } - return sortedTenantIds; + return lendingTenantIds; } private Map> getItemStatusOccurrencesByTenant(String instanceId) { @@ -72,7 +82,7 @@ private Map> getItemStatusOccurrencesByTenant(String i .flatMap(Collection::stream) .filter(item -> item.getTenantId() != null) .collect(collectingAndThen(groupingBy(Item::getTenantId), - ItemStatusBasedTenantPickingStrategy::mapItemsToItemStatusOccurrences)); + TenantServiceImpl::mapItemsToItemStatusOccurrences)); } @NotNull diff --git a/src/main/java/org/folio/service/impl/UserServiceImpl.java b/src/main/java/org/folio/service/impl/UserServiceImpl.java new file mode 100644 index 00000000..19a6c424 --- /dev/null +++ b/src/main/java/org/folio/service/impl/UserServiceImpl.java @@ -0,0 +1,80 @@ +package org.folio.service.impl; + +import java.util.Optional; + +import org.folio.client.feign.UsersClient; +import org.folio.domain.dto.User; +import org.folio.domain.dto.UserPersonal; +import org.folio.domain.dto.UserType; +import org.folio.exception.TenantScopedExecutionException; +import org.folio.service.TenantScopedExecutionService; +import org.folio.service.UserService; +import org.springframework.stereotype.Service; + +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class UserServiceImpl implements UserService { + + private final UsersClient usersClient; + private final TenantScopedExecutionService tenantScopedExecutionService; + + public User createShadowUser(User realUser, String tenantId) { + final String userId = realUser.getId(); + log.info("createShadowUser:: creating shadow user {} in tenant {}", userId, tenantId); + try { + User user = findUser(userId, tenantId); + log.info("createShadowUser:: user {} already exists in tenant {}", userId, tenantId); + return user; + } catch (TenantScopedExecutionException e) { + log.warn("createShadowUser:: failed to find user {} in tenant {}", userId, tenantId); + return Optional.ofNullable(e.getCause()) + .filter(FeignException.NotFound.class::isInstance) + .map(ignored -> createUser(buildShadowUser(realUser), tenantId)) + .orElseThrow(() -> e); + } + } + + public User findUser(String userId, String tenantId) { + log.info("findUser:: looking up user {} in tenant {}", userId, tenantId); + User user = tenantScopedExecutionService.execute(tenantId, () -> usersClient.getUser(userId)); + log.info("findUser:: user {} found in tenant {}", userId, tenantId); + log.debug("findUser:: user: {}", () -> user); + + return user; + } + + private User createUser(User user, String tenantId) { + log.info("createUser:: creating user {} in tenant {}", user.getId(), tenantId); + User newUser = tenantScopedExecutionService.execute(tenantId, () -> usersClient.postUser(user)); + log.info("createUser:: user {} was created in tenant {}", user.getId(), tenantId); + log.debug("createUser:: user: {}", () -> newUser); + + return newUser; + } + + private static User buildShadowUser(User realUser) { + User shadowUser = new User() + .id(realUser.getId()) + .username(realUser.getUsername()) + .patronGroup(realUser.getPatronGroup()) + .type(UserType.SHADOW.getValue()) + .active(true); + + UserPersonal personal = realUser.getPersonal(); + if (personal != null) { + shadowUser.setPersonal(new UserPersonal() + .firstName(personal.getFirstName()) + .lastName(personal.getLastName()) + ); + } + + log.debug("buildShadowUser:: result: {}", () -> shadowUser); + return shadowUser; + } + +} diff --git a/src/main/java/org/folio/util/HttpUtils.java b/src/main/java/org/folio/util/HttpUtils.java new file mode 100644 index 00000000..69d4c2ca --- /dev/null +++ b/src/main/java/org/folio/util/HttpUtils.java @@ -0,0 +1,68 @@ +package org.folio.util; + +import java.util.Arrays; +import java.util.Base64; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.experimental.UtilityClass; +import lombok.extern.log4j.Log4j2; + +@UtilityClass +@Log4j2 +public class HttpUtils { + private static final String ACCESS_TOKEN_COOKIE_NAME = "folioAccessToken"; + private static final String TOKEN_SECTION_SEPARATOR = "\\."; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public static Optional getTenantFromToken() { + return getCurrentRequest() + .flatMap(request -> getCookie(request, ACCESS_TOKEN_COOKIE_NAME)) + .flatMap(HttpUtils::extractTenantFromToken); + } + + public static Optional getCurrentRequest() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast) + .map(ServletRequestAttributes::getRequest); + } + + public static Optional getCookie(HttpServletRequest request, String cookieName) { + return Optional.ofNullable(request) + .map(HttpServletRequest::getCookies) + .flatMap(cookies -> getCookie(cookies, cookieName)) + .map(Cookie::getValue); + } + + private static Optional getCookie(Cookie[] cookies, String cookieName) { + return Arrays.stream(cookies) + .filter(cookie -> StringUtils.equals(cookie.getName(), cookieName)) + .findFirst(); + } + + private static Optional extractTenantFromToken(String token) { + log.info("extractTenantFromToken:: extracting tenant ID from token"); + try { + byte[] decodedPayload = Base64.getDecoder() + .decode(token.split(TOKEN_SECTION_SEPARATOR)[1]); + String tenantId = OBJECT_MAPPER.readTree(decodedPayload) + .get("tenant") + .asText(); + + log.info("extractTenantFromToken:: successfully extracted tenant ID from token: {}", tenantId); + return Optional.ofNullable(tenantId); + } catch (Exception e) { + log.error("getTenantFromToken:: failed to extract tenant ID from token", e); + return Optional.empty(); + } + } + +} diff --git a/src/main/resources/swagger.api/ecs-tlr.yaml b/src/main/resources/swagger.api/ecs-tlr.yaml index 83a1d7be..274ecfb9 100644 --- a/src/main/resources/swagger.api/ecs-tlr.yaml +++ b/src/main/resources/swagger.api/ecs-tlr.yaml @@ -85,6 +85,8 @@ components: $ref: 'schemas/request.json' searchInstancesResponse: $ref: schemas/response/searchInstancesResponse.json + user: + $ref: schemas/user.json parameters: requestId: name: requestId diff --git a/src/main/resources/swagger.api/schemas/EcsTlr.yaml b/src/main/resources/swagger.api/schemas/EcsTlr.yaml index e6d74066..6c8fcda9 100644 --- a/src/main/resources/swagger.api/schemas/EcsTlr.yaml +++ b/src/main/resources/swagger.api/schemas/EcsTlr.yaml @@ -40,6 +40,12 @@ EcsTlr: itemId: description: "ID of the item being requested" $ref: "uuid.yaml" + primaryRequestId: + description: "Primary request ID" + $ref: "uuid.yaml" + primaryRequestTenantId: + description: "ID of the tenant primary request was created in" + type: string secondaryRequestId: description: "Secondary request ID" $ref: "uuid.yaml" diff --git a/src/main/resources/swagger.api/schemas/user.json b/src/main/resources/swagger.api/schemas/user.json new file mode 100644 index 00000000..505db4f3 --- /dev/null +++ b/src/main/resources/swagger.api/schemas/user.json @@ -0,0 +1,191 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "User Schema", + "description": "A user", + "javaName": "User", + "type": "object", + "properties": { + "username": { + "description": "A unique name belonging to a user. Typically used for login", + "type": "string" + }, + "id": { + "description" : "A globally unique (UUID) identifier for the user", + "type": "string" + }, + "externalSystemId": { + "description": "A unique ID that corresponds to an external authority", + "type": "string" + }, + "barcode": { + "description": "The unique library barcode for this user", + "type": "string" + }, + "active": { + "description": "A flag to determine if the user's account is effective and not expired. The tenant configuration can require the user to be active for login. Active is different from the loan patron block", + "type": "boolean" + }, + "type": { + "description": "The class of user like staff or patron; this is different from patronGroup; it can store shadow, system user and dcb types also", + "type": "string" + }, + "patronGroup": { + "description": "A UUID corresponding to the group the user belongs to, see /groups API, example groups are undergraduate and faculty; loan rules, patron blocks, fees/fines and expiration days can use the patron group", + "type": "string", + "$ref": "uuid.json" + }, + "departments": { + "description": "A list of UUIDs corresponding to the departments the user belongs to, see /departments API", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "$ref": "uuid.json" + } + }, + "meta": { + "description": "Deprecated", + "type": "object" + }, + "proxyFor": { + "description" : "Deprecated", + "type": "array", + "items": { + "type": "string" + } + }, + "personal": { + "description": "Personal information about the user", + "type": "object", + "properties": { + "lastName": { + "description": "The user's surname", + "type": "string" + }, + "firstName": { + "description": "The user's given name", + "type": "string" + }, + "middleName": { + "description": "The user's middle name (if any)", + "type": "string" + }, + "preferredFirstName": { + "description": "The user's preferred name", + "type": "string" + }, + "email": { + "description": "The user's email address", + "type": "string" + }, + "phone": { + "description": "The user's primary phone number", + "type": "string" + }, + "mobilePhone": { + "description": "The user's mobile phone number", + "type": "string" + }, + "dateOfBirth": { + "type": "string", + "description": "The user's birth date", + "format": "date-time" + }, + "addresses": { + "description": "Physical addresses associated with the user", + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "properties": { + "id": { + "description": "A unique id for this address", + "type": "string" + }, + "countryId": { + "description": "The country code for this address", + "type": "string" + }, + "addressLine1": { + "description": "Address, Line 1", + "type": "string" + }, + "addressLine2": { + "description": "Address, Line 2", + "type": "string" + }, + "city": { + "description": "City name", + "type": "string" + }, + "region": { + "description": "Region", + "type": "string" + }, + "postalCode": { + "description": "Postal Code", + "type": "string" + }, + "addressTypeId": { + "description": "A UUID that corresponds with an address type object", + "type": "string", + "$ref": "uuid.json" + }, + "primaryAddress": { + "description": "Is this the user's primary address?", + "type": "boolean" + } + }, + "required":[ + "addressTypeId" + ] + } + }, + "preferredContactTypeId": { + "description": "Id of user's preferred contact type like Email, Mail or Text Message, see /addresstypes API", + "type": "string" + }, + "profilePictureLink": { + "description": "Link to the profile picture", + "type": "string", + "format": "uri" + } + }, + "required": [ + "lastName" + ] + }, + "enrollmentDate": { + "description": "The date in which the user joined the organization", + "type": "string", + "format": "date-time" + }, + "expirationDate": { + "description": "The date for when the user becomes inactive", + "type": "string", + "format": "date-time" + }, + "createdDate": { + "description": "Deprecated", + "type": "string", + "format": "date-time" + }, + "updatedDate": { + "description": "Deprecated", + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "$ref": "metadata.json" + }, + "tags": { + "type": "object", + "$ref": "tags.json" + }, + "customFields" : { + "description": "Object that contains custom field", + "type": "object" + } + } +} \ 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 06cc4b6b..eff96bb9 100644 --- a/src/test/java/org/folio/api/BaseIT.java +++ b/src/test/java/org/folio/api/BaseIT.java @@ -8,6 +8,7 @@ import lombok.SneakyThrows; import org.folio.spring.integration.XOkapiHeaders; import org.folio.tenant.domain.dto.TenantAttributes; +import org.folio.util.TestUtils; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -43,7 +44,7 @@ @Testcontainers public class BaseIT { protected static final String TOKEN = "test_token"; - protected static final String TENANT_ID_DIKU = "diku"; + protected static final String TENANT_ID_CONSORTIUM = "consortium"; // central tenant protected static final String TENANT_ID_UNIVERSITY = "university"; protected static final String TENANT_ID_COLLEGE = "college"; @@ -92,7 +93,7 @@ public static HttpHeaders defaultHeaders() { final HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.setContentType(APPLICATION_JSON); - httpHeaders.put(XOkapiHeaders.TENANT, List.of(TENANT_ID_DIKU)); + httpHeaders.put(XOkapiHeaders.TENANT, List.of(TENANT_ID_CONSORTIUM)); httpHeaders.add(XOkapiHeaders.URL, wireMockServer.baseUrl()); httpHeaders.add(XOkapiHeaders.TOKEN, TOKEN); httpHeaders.add(XOkapiHeaders.USER_ID, "08d51c7a-0f36-4f3d-9e35-d285612a23df"); @@ -133,14 +134,23 @@ protected WebTestClient.RequestBodySpec buildRequest(HttpMethod method, String u .uri(uri) .accept(APPLICATION_JSON) .contentType(APPLICATION_JSON) - .header(XOkapiHeaders.TENANT, TENANT_ID_DIKU) + .header(XOkapiHeaders.TENANT, TENANT_ID_CONSORTIUM) .header(XOkapiHeaders.URL, wireMockServer.baseUrl()) .header(XOkapiHeaders.TOKEN, TOKEN) .header(XOkapiHeaders.USER_ID, randomId()); } protected WebTestClient.ResponseSpec doPost(String url, Object payload) { + return doPostWithTenant(url, payload, TENANT_ID_CONSORTIUM); + } + + protected WebTestClient.ResponseSpec doPostWithTenant(String url, Object payload, String tenantId) { + return doPostWithToken(url, payload, TestUtils.buildToken(tenantId)); + } + + protected WebTestClient.ResponseSpec doPostWithToken(String url, Object payload, String token) { return buildRequest(HttpMethod.POST, url) + .cookie("folioAccessToken", token) .body(BodyInserters.fromValue(payload)) .exchange(); } diff --git a/src/test/java/org/folio/api/EcsTlrApiTest.java b/src/test/java/org/folio/api/EcsTlrApiTest.java index 54de721a..5b51180b 100644 --- a/src/test/java/org/folio/api/EcsTlrApiTest.java +++ b/src/test/java/org/folio/api/EcsTlrApiTest.java @@ -2,8 +2,10 @@ import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.jsonResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.notFound; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; import static java.util.stream.Collectors.toMap; @@ -25,15 +27,21 @@ import org.folio.domain.dto.ItemStatus; import org.folio.domain.dto.Request; import org.folio.domain.dto.SearchInstancesResponse; +import org.folio.domain.dto.User; +import org.folio.domain.dto.UserPersonal; +import org.folio.domain.dto.UserType; import org.folio.spring.FolioExecutionContext; import org.folio.spring.FolioModuleMetadata; import org.folio.spring.scope.FolioExecutionContextSetter; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; import com.github.tomakehurst.wiremock.client.WireMock; class EcsTlrApiTest extends BaseIT { @@ -41,6 +49,8 @@ class EcsTlrApiTest extends BaseIT { private static final String TENANT_HEADER = "x-okapi-tenant"; private static final String INSTANCE_ID = randomId(); private static final String INSTANCE_REQUESTS_URL = "/circulation/requests/instances"; + private static final String REQUESTS_URL = "/circulation/requests"; + private static final String USERS_URL = "/users"; private static final String SEARCH_INSTANCES_URL = "/search/instances\\?query=id==" + INSTANCE_ID + "&expandAll=true"; @@ -53,6 +63,7 @@ class EcsTlrApiTest extends BaseIT { @BeforeEach public void beforeEach() { contextSetter = initContext(); + wireMockServer.resetAll(); } @AfterEach @@ -69,73 +80,178 @@ void getByIdNotFound() throws Exception { .andExpect(status().isNotFound()); } - @Test - void titleLevelRequestIsCreatedForDifferentTenant() { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void ecsTlrIsCreated(boolean shadowUserExists) { String instanceRequestId = randomId(); String availableItemId = randomId(); - EcsTlr ecsTlr = buildEcsTlr(INSTANCE_ID); + String requesterId = randomId(); + EcsTlr ecsTlr = buildEcsTlr(INSTANCE_ID, requesterId); String ecsTlrJson = asJsonString(ecsTlr); + // 1. Create mock responses from other modules + SearchInstancesResponse mockSearchInstancesResponse = new SearchInstancesResponse() .totalRecords(2) .instances(List.of( new Instance().id(INSTANCE_ID) - .tenantId(TENANT_ID_DIKU) + .tenantId(TENANT_ID_CONSORTIUM) .items(List.of( buildItem(randomId(), TENANT_ID_UNIVERSITY, "Checked out"), buildItem(randomId(), TENANT_ID_UNIVERSITY, "In transit"), buildItem(availableItemId, TENANT_ID_COLLEGE, "Available"))) )); - Request mockInstanceRequestResponse = new Request() + Request mockSecondaryRequestResponse = new Request() .id(instanceRequestId) + .requesterId(requesterId) .requestLevel(Request.RequestLevelEnum.TITLE) .requestType(Request.RequestTypeEnum.PAGE) .instanceId(INSTANCE_ID) - .itemId(availableItemId); + .itemId(availableItemId) + .pickupServicePointId(randomId()); + + Request mockPrimaryRequestResponse = new Request() + .id(mockSecondaryRequestResponse.getId()) + .instanceId(INSTANCE_ID) + .requesterId(requesterId) + .requestDate(mockSecondaryRequestResponse.getRequestDate()) + .requestLevel(Request.RequestLevelEnum.TITLE) + .requestType(Request.RequestTypeEnum.HOLD) + .fulfillmentPreference(Request.FulfillmentPreferenceEnum.HOLD_SHELF) + .pickupServicePointId(mockSecondaryRequestResponse.getPickupServicePointId()); + + User mockUser = buildUser(requesterId); + User mockShadowUser = buildShadowUser(mockUser); - wireMockServer.stubFor(WireMock.get(urlMatching(".*" + SEARCH_INSTANCES_URL)) + // 2. Create stubs for other modules + + wireMockServer.stubFor(WireMock.get(urlMatching(SEARCH_INSTANCES_URL)) .willReturn(jsonResponse(mockSearchInstancesResponse, HttpStatus.SC_OK))); - wireMockServer.stubFor(WireMock.post(urlMatching(".*" + INSTANCE_REQUESTS_URL)) - .willReturn(jsonResponse(asJsonString(mockInstanceRequestResponse), HttpStatus.SC_CREATED))); + // requester exists in local tenant + wireMockServer.stubFor(WireMock.get(urlMatching(USERS_URL + "/" + requesterId)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM)) + .willReturn(jsonResponse(mockUser, HttpStatus.SC_OK))); + + ResponseDefinitionBuilder mockGetShadowUserResponse = shadowUserExists + ? jsonResponse(mockShadowUser, HttpStatus.SC_OK) + : notFound(); + + wireMockServer.stubFor(WireMock.get(urlMatching(USERS_URL + "/" + requesterId)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_COLLEGE)) + .willReturn(mockGetShadowUserResponse)); + + wireMockServer.stubFor(WireMock.post(urlMatching(USERS_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_COLLEGE)) + .willReturn(jsonResponse(mockShadowUser, HttpStatus.SC_CREATED))); + + wireMockServer.stubFor(WireMock.post(urlMatching(INSTANCE_REQUESTS_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_COLLEGE)) + .willReturn(jsonResponse(asJsonString(mockSecondaryRequestResponse), HttpStatus.SC_CREATED))); + + wireMockServer.stubFor(WireMock.post(urlMatching(REQUESTS_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM)) + .willReturn(jsonResponse(asJsonString(mockPrimaryRequestResponse), HttpStatus.SC_CREATED))); + + // 3. Create ECS TLR EcsTlr expectedPostEcsTlrResponse = fromJsonString(ecsTlrJson, EcsTlr.class) .secondaryRequestId(instanceRequestId) .secondaryRequestTenantId(TENANT_ID_COLLEGE) .itemId(availableItemId); - assertEquals(TENANT_ID_DIKU, getCurrentTenantId()); - doPost(TLR_URL, ecsTlr) + assertEquals(TENANT_ID_CONSORTIUM, getCurrentTenantId()); + doPostWithTenant(TLR_URL, ecsTlr, TENANT_ID_CONSORTIUM) .expectStatus().isCreated() .expectBody().json(asJsonString(expectedPostEcsTlrResponse), true); - assertEquals(TENANT_ID_DIKU, getCurrentTenantId()); + assertEquals(TENANT_ID_CONSORTIUM, getCurrentTenantId()); + + // 4. Verify calls to other modules - wireMockServer.verify(getRequestedFor(urlMatching(".*" + SEARCH_INSTANCES_URL)) - .withHeader(TENANT_HEADER, equalTo(TENANT_ID_DIKU))); + wireMockServer.verify(getRequestedFor(urlMatching(SEARCH_INSTANCES_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM))); - wireMockServer.verify(postRequestedFor(urlMatching(".*" + INSTANCE_REQUESTS_URL)) + wireMockServer.verify(getRequestedFor(urlMatching(USERS_URL + "/" + requesterId)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM))); + + wireMockServer.verify(getRequestedFor(urlMatching(USERS_URL + "/" + requesterId)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_COLLEGE))); + + wireMockServer.verify(postRequestedFor(urlMatching(INSTANCE_REQUESTS_URL)) .withHeader(TENANT_HEADER, equalTo(TENANT_ID_COLLEGE)) // because this tenant has available item .withRequestBody(equalToJson(ecsTlrJson))); + + wireMockServer.verify(postRequestedFor(urlMatching(REQUESTS_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM)) + .withRequestBody(equalToJson(asJsonString(mockPrimaryRequestResponse)))); + + if (shadowUserExists) { + wireMockServer.verify(exactly(0), postRequestedFor(urlMatching(USERS_URL))); + } else { + wireMockServer.verify(postRequestedFor(urlMatching(USERS_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_COLLEGE)) + .withRequestBody(equalToJson(asJsonString(mockShadowUser)))); + } + } + + @Test + void canNotCreateEcsTlrWhenFailedToExtractBorrowingTenantIdFromToken() { + EcsTlr ecsTlr = buildEcsTlr(INSTANCE_ID, randomId()); + doPostWithToken(TLR_URL, ecsTlr, "not_a_token") + .expectStatus().isEqualTo(500); + + wireMockServer.verify(exactly(0), getRequestedFor(urlMatching(SEARCH_INSTANCES_URL))); } @Test - void canNotCreateEcsTlrWhenFailedToPickTenant() { - EcsTlr ecsTlr = buildEcsTlr(INSTANCE_ID); + void canNotCreateEcsTlrWhenFailedToPickLendingTenant() { + EcsTlr ecsTlr = buildEcsTlr(INSTANCE_ID, randomId()); SearchInstancesResponse mockSearchInstancesResponse = new SearchInstancesResponse() .totalRecords(0) .instances(List.of()); - wireMockServer.stubFor(WireMock.get(urlMatching(".*" + SEARCH_INSTANCES_URL)) + wireMockServer.stubFor(WireMock.get(urlMatching(SEARCH_INSTANCES_URL)) .willReturn(jsonResponse(mockSearchInstancesResponse, HttpStatus.SC_OK))); doPost(TLR_URL, ecsTlr) .expectStatus().isEqualTo(500); - wireMockServer.verify(getRequestedFor(urlMatching(".*" + SEARCH_INSTANCES_URL)) - .withHeader(TENANT_HEADER, equalTo(TENANT_ID_DIKU))); + wireMockServer.verify(getRequestedFor(urlMatching(SEARCH_INSTANCES_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM))); + + wireMockServer.verify(exactly(0), postRequestedFor(urlMatching(USERS_URL))); + } + + @Test + void canNotCreateEcsTlrWhenFailedToFindRequesterInBorrowingTenant() { + String requesterId = randomId(); + EcsTlr ecsTlr = buildEcsTlr(INSTANCE_ID, requesterId); + SearchInstancesResponse mockSearchInstancesResponse = new SearchInstancesResponse() + .totalRecords(2) + .instances(List.of( + new Instance().id(INSTANCE_ID) + .tenantId(TENANT_ID_CONSORTIUM) + .items(List.of(buildItem(randomId(), TENANT_ID_UNIVERSITY, "Available"))) + )); + + wireMockServer.stubFor(WireMock.get(urlMatching(SEARCH_INSTANCES_URL)) + .willReturn(jsonResponse(mockSearchInstancesResponse, HttpStatus.SC_OK))); + + wireMockServer.stubFor(WireMock.get(urlMatching(USERS_URL + "/" + requesterId)) + .willReturn(notFound())); + + doPost(TLR_URL, ecsTlr) + .expectStatus().isEqualTo(500); + + wireMockServer.verify(getRequestedFor(urlMatching(SEARCH_INSTANCES_URL)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM))); + + wireMockServer.verify(getRequestedFor(urlMatching(USERS_URL + "/" + requesterId)) + .withHeader(TENANT_HEADER, equalTo(TENANT_ID_CONSORTIUM))); } + private String getCurrentTenantId() { return context.getTenantId(); } @@ -150,14 +266,15 @@ private FolioExecutionContextSetter initContext() { return new FolioExecutionContextSetter(moduleMetadata, buildDefaultHeaders()); } - private static EcsTlr buildEcsTlr(String instanceId) { + private static EcsTlr buildEcsTlr(String instanceId, String requesterId) { return new EcsTlr() .id(randomId()) .instanceId(instanceId) - .requesterId(randomId()) + .requesterId(requesterId) .pickupServicePointId(randomId()) .fulfillmentPreference(EcsTlr.FulfillmentPreferenceEnum.DELIVERY) .patronComments("random comment") + .requestDate(new Date()) .requestExpirationDate(new Date()); } @@ -168,4 +285,36 @@ private static Item buildItem(String id, String tenantId, String status) { .status(new ItemStatus().name(status)); } + private static User buildUser(String userId) { + return new User() + .id(userId) + .username("test_user") + .patronGroup(randomId()) + .type("patron") + .active(true) + .personal(new UserPersonal() + .firstName("First") + .middleName("Middle") + .lastName("Last")); + } + + private static User buildShadowUser(User realUser) { + User shadowUser = new User() + .id(realUser.getId()) + .username(realUser.getUsername()) + .patronGroup(realUser.getPatronGroup()) + .type(UserType.SHADOW.getValue()) + .active(true); + + UserPersonal personal = realUser.getPersonal(); + if (personal != null) { + shadowUser.setPersonal(new UserPersonal() + .firstName(personal.getFirstName()) + .lastName(personal.getLastName()) + ); + } + + return shadowUser; + } + } diff --git a/src/test/java/org/folio/domain/strategy/ItemStatusBasedTenantPickingStrategyTest.java b/src/test/java/org/folio/domain/strategy/TenantServiceTest.java similarity index 91% rename from src/test/java/org/folio/domain/strategy/ItemStatusBasedTenantPickingStrategyTest.java rename to src/test/java/org/folio/domain/strategy/TenantServiceTest.java index 094bd640..fde0f021 100644 --- a/src/test/java/org/folio/domain/strategy/ItemStatusBasedTenantPickingStrategyTest.java +++ b/src/test/java/org/folio/domain/strategy/TenantServiceTest.java @@ -10,10 +10,12 @@ import java.util.stream.Stream; import org.folio.client.feign.SearchClient; +import org.folio.domain.dto.EcsTlr; import org.folio.domain.dto.Instance; import org.folio.domain.dto.Item; import org.folio.domain.dto.ItemStatus; import org.folio.domain.dto.SearchInstancesResponse; +import org.folio.service.impl.TenantServiceImpl; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -30,17 +32,17 @@ class ItemStatusBasedTenantPickingStrategyTest { @Mock private SearchClient searchClient; @InjectMocks - private ItemStatusBasedTenantPickingStrategy strategy; + private TenantServiceImpl tenantService; @ParameterizedTest - @MethodSource("parametersForPickTenant") - void pickTenant(List expectedTenantIds, Instance instance) { + @MethodSource("parametersForGetLendingTenants") + void getLendingTenants(List expectedTenantIds, Instance instance) { Mockito.when(searchClient.searchInstance(Mockito.any())) .thenReturn(new SearchInstancesResponse().instances(singletonList(instance))); - assertEquals(expectedTenantIds, strategy.findTenants(INSTANCE_ID)); + assertEquals(expectedTenantIds, tenantService.getLendingTenants(new EcsTlr().instanceId(INSTANCE_ID))); } - private static Stream parametersForPickTenant() { + private static Stream parametersForGetLendingTenants() { return Stream.of( Arguments.of(emptyList(), null), @@ -54,7 +56,7 @@ private static Stream parametersForPickTenant() { buildItem("a", "Paged") )), -// 1 tenant, 1 item + // 1 tenant, 1 item Arguments.of(List.of("a"), buildInstance(buildItem("a", "Available"))), Arguments.of(List.of("a"), buildInstance(buildItem("a", "Checked out"))), Arguments.of(List.of("a"), buildInstance(buildItem("a", "In transit"))), @@ -142,4 +144,4 @@ private static Item buildItem(String tenantId, String status) { .status(new ItemStatus().name(status)); } -} +} \ No newline at end of file diff --git a/src/test/java/org/folio/service/EcsTlrServiceTest.java b/src/test/java/org/folio/service/EcsTlrServiceTest.java index dd6bfde6..b788994e 100644 --- a/src/test/java/org/folio/service/EcsTlrServiceTest.java +++ b/src/test/java/org/folio/service/EcsTlrServiceTest.java @@ -1,32 +1,33 @@ package org.folio.service; +import static java.util.Collections.emptyList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; 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.Collection; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.UUID; +import org.folio.domain.RequestWrapper; import org.folio.domain.dto.EcsTlr; import org.folio.domain.dto.Request; +import org.folio.domain.dto.User; import org.folio.domain.entity.EcsTlrEntity; import org.folio.domain.mapper.EcsTlrMapper; import org.folio.domain.mapper.EcsTlrMapperImpl; -import org.folio.domain.strategy.TenantPickingStrategy; import org.folio.exception.TenantPickingException; -import org.folio.exception.TenantScopedExecutionException; import org.folio.repository.EcsTlrRepository; import org.folio.service.impl.EcsTlrServiceImpl; import org.joda.time.DateTime; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; @@ -38,11 +39,15 @@ class EcsTlrServiceTest { @InjectMocks private EcsTlrServiceImpl ecsTlrService; @Mock + private UserService userService; + @Mock + private RequestService requestService; + @Mock private EcsTlrRepository ecsTlrRepository; @Mock private TenantScopedExecutionService tenantScopedExecutionService; @Mock - private TenantPickingStrategy tenantPickingStrategy; + private TenantService tenantService; @Spy private final EcsTlrMapper ecsTlrMapper = new EcsTlrMapperImpl(); @@ -64,6 +69,8 @@ void ecsTlrShouldBeCreatedThenUpdatedAndDeleted() { var requestExpirationDate = DateTime.now().plusDays(7).toDate(); var requestDate = DateTime.now().toDate(); var patronComments = "Test comment"; + var borrowingTenant = "borrowing-tenant"; + var lendingTenant = "lending-tenant"; var mockEcsTlrEntity = new EcsTlrEntity(); mockEcsTlrEntity.setId(id); @@ -90,10 +97,15 @@ void ecsTlrShouldBeCreatedThenUpdatedAndDeleted() { ecsTlr.setPickupServicePointId(pickupServicePointId.toString()); when(ecsTlrRepository.save(any(EcsTlrEntity.class))).thenReturn(mockEcsTlrEntity); - when(tenantPickingStrategy.findTenants(any(String.class))) - .thenReturn(List.of("random-tenant")); - when(tenantScopedExecutionService.execute(any(String.class), any())) - .thenReturn(new Request().id(UUID.randomUUID().toString())); + when(tenantService.getBorrowingTenant(any(EcsTlr.class))) + .thenReturn(Optional.of(borrowingTenant)); + when(tenantService.getLendingTenants(any(EcsTlr.class))) + .thenReturn(List.of(lendingTenant)); + when(requestService.createPrimaryRequest(any(Request.class), any(String.class))) + .thenReturn(new RequestWrapper(new Request(), borrowingTenant)); + when(requestService.createSecondaryRequest(any(Request.class), any(String.class), any())) + .thenReturn(new RequestWrapper(new Request(), borrowingTenant)); + var postEcsTlr = ecsTlrService.create(ecsTlr); assertEquals(id.toString(), postEcsTlr.getId()); @@ -116,40 +128,30 @@ void ecsTlrShouldBeCreatedThenUpdatedAndDeleted() { } @Test - void canNotCreateRemoteRequestWhenFailedToPickTenant() { - when(tenantPickingStrategy.findTenants(any(String.class))) - .thenReturn(Collections.emptyList()); + void canNotCreateEcsTlrWhenFailedToGetBorrowingTenantId() { String instanceId = UUID.randomUUID().toString(); EcsTlr ecsTlr = new EcsTlr().instanceId(instanceId); + when(tenantService.getBorrowingTenant(ecsTlr)) + .thenReturn(Optional.empty()); TenantPickingException exception = assertThrows(TenantPickingException.class, () -> ecsTlrService.create(ecsTlr)); - assertEquals("Failed to find tenants for instance " + instanceId, exception.getMessage()); + assertEquals("Failed to get borrowing tenant", exception.getMessage()); } @Test - void canCreateRemoteRequestOnlyForSecondTenantId() { + void canNotCreateEcsTlrWhenFailedToGetLendingTenants() { String instanceId = UUID.randomUUID().toString(); - EcsTlr ecsTlr = new EcsTlr() - .instanceId(instanceId) - .id(UUID.randomUUID().toString()); - String firstTenantId = UUID.randomUUID().toString(); - String secondTenantId = UUID.randomUUID().toString(); - String thirdTenantId = UUID.randomUUID().toString(); - - List mockTenantIds = List.of( - firstTenantId, secondTenantId, thirdTenantId); - when(tenantPickingStrategy.findTenants(any(String.class))) - .thenReturn(mockTenantIds); - when(tenantScopedExecutionService.execute(any(), any())) - .thenThrow(new TenantScopedExecutionException(new RuntimeException("Test failure"), firstTenantId)) - .thenReturn(new Request().id(UUID.randomUUID().toString())) - .thenReturn(new Request().id(UUID.randomUUID().toString())); - ecsTlrService.create(ecsTlr); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EcsTlrEntity.class); - verify(ecsTlrRepository, times(1)).save(captor.capture()); - assertEquals(secondTenantId, captor.getValue().getSecondaryRequestTenantId()); + EcsTlr ecsTlr = new EcsTlr().instanceId(instanceId); + when(tenantService.getBorrowingTenant(ecsTlr)) + .thenReturn(Optional.of("borrowing_tenant")); + when(tenantService.getLendingTenants(ecsTlr)) + .thenReturn(emptyList()); + + TenantPickingException exception = assertThrows(TenantPickingException.class, + () -> ecsTlrService.create(ecsTlr)); + + assertEquals("Failed to find lending tenants for instance " + instanceId, exception.getMessage()); } } diff --git a/src/test/java/org/folio/util/TestUtils.java b/src/test/java/org/folio/util/TestUtils.java new file mode 100644 index 00000000..b7579656 --- /dev/null +++ b/src/test/java/org/folio/util/TestUtils.java @@ -0,0 +1,33 @@ +package org.folio.util; + +import java.util.Base64; + +import org.json.JSONObject; + +import lombok.SneakyThrows; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class TestUtils { + + @SneakyThrows + public static String buildToken(String tenantId) { + JSONObject header = new JSONObject() + .put("alg", "HS256"); + + JSONObject payload = new JSONObject() + .put("sub", tenantId + "_admin") + .put("user_id", "bb6a6f19-9275-4261-ad9d-6c178c24c4fb") + .put("type", "access") + .put("exp", 1708342543) + .put("iat", 1708341943) + .put("tenant", tenantId); + + String signature = "De_0um7P_Rv-diqjHKLcSHZdjzjjshvlBbi6QPrz0Tw"; + + return String.format("%s.%s.%s", + Base64.getEncoder().encodeToString(header.toString().getBytes()), + Base64.getEncoder().encodeToString(payload.toString().getBytes()), + signature); + } +}