diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 7d4581ed..94d6d14e 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -16,7 +16,9 @@ "methods": ["POST"], "pathPattern": "/tlr/ecs-tlr", "permissionsRequired": ["tlr.ecs-tlr.post"], - "modulePermissions": [] + "modulePermissions": [ + "circulation.requests.item.post" + ] } ] }, @@ -71,9 +73,22 @@ } }, "env": [ - { "name": "JAVA_OPTIONS", + { + "name": "JAVA_OPTIONS", "value": "-XX:MaxRAMPercentage=66.0" }, + { + "name": "OKAPI_URL", + "value": "http://okapi:9130" + }, + { + "name": "KAFKA_HOST", + "value": "kafka" + }, + { + "name": "KAFKA_PORT", + "value": "9092" + }, { "name": "DB_HOST", "value": "postgres" }, { "name": "DB_PORT", "value": "5432" }, { "name": "DB_USERNAME", "value": "folio_admin" }, diff --git a/pom.xml b/pom.xml index 2f422aa0..043b812f 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,11 @@ provided + + org.springframework + spring-webflux + + org.springframework.boot diff --git a/src/main/java/org/folio/client/CirculationClient.java b/src/main/java/org/folio/client/CirculationClient.java new file mode 100644 index 00000000..2c0cb4bc --- /dev/null +++ b/src/main/java/org/folio/client/CirculationClient.java @@ -0,0 +1,13 @@ +package org.folio.client; + +import org.folio.domain.dto.Request; +import org.folio.spring.config.FeignClientConfiguration; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; + +@FeignClient(name = "circulation", url = "${folio.okapi-url}", configuration = FeignClientConfiguration.class) +public interface CirculationClient { + + @PostMapping("/circulation/requests") + Request createRequest(Request request); +} diff --git a/src/main/java/org/folio/domain/entity/EcsTlrEntity.java b/src/main/java/org/folio/domain/entity/EcsTlrEntity.java index 77984b48..84343d04 100644 --- a/src/main/java/org/folio/domain/entity/EcsTlrEntity.java +++ b/src/main/java/org/folio/domain/entity/EcsTlrEntity.java @@ -26,6 +26,7 @@ public class EcsTlrEntity { private String requestType; private String requestLevel; private Date requestExpirationDate; + private Date requestDate; private String patronComments; private String fulfillmentPreference; private UUID pickupServicePointId; diff --git a/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java b/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java index f4d97f9d..96285bb6 100644 --- a/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java +++ b/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java @@ -1,6 +1,7 @@ package org.folio.domain.mapper; import org.folio.domain.dto.EcsTlr; +import org.folio.domain.dto.Request; import org.folio.domain.entity.EcsTlrEntity; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -49,4 +50,6 @@ default String mapRequestLevelToString(EcsTlr.RequestLevelEnum requestLevelEnum) default String mapFulfillmentPreferenceToString(EcsTlr.FulfillmentPreferenceEnum fulfillmentPreferenceEnum) { return fulfillmentPreferenceEnum != null ? fulfillmentPreferenceEnum.getValue() : null; } + + Request mapDtoToRequest(EcsTlr ecsTlr); } diff --git a/src/main/java/org/folio/exception/TenantScopedExecutionException.java b/src/main/java/org/folio/exception/TenantScopedExecutionException.java new file mode 100644 index 00000000..dec59750 --- /dev/null +++ b/src/main/java/org/folio/exception/TenantScopedExecutionException.java @@ -0,0 +1,13 @@ +package org.folio.exception; + +import lombok.Getter; + +@Getter +public class TenantScopedExecutionException extends RuntimeException { + private final String tenantId; + + public TenantScopedExecutionException(Exception cause, String tenantId) { + super(cause); + this.tenantId = tenantId; + } +} diff --git a/src/main/java/org/folio/service/TenantScopedExecutionService.java b/src/main/java/org/folio/service/TenantScopedExecutionService.java new file mode 100644 index 00000000..d20bf68c --- /dev/null +++ b/src/main/java/org/folio/service/TenantScopedExecutionService.java @@ -0,0 +1,8 @@ +package org.folio.service; + +import java.util.concurrent.Callable; + +public interface TenantScopedExecutionService { + + T execute(String tenantId, Callable action); +} diff --git a/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java b/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java index 43d20595..7456516d 100644 --- a/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java +++ b/src/main/java/org/folio/service/impl/EcsTlrServiceImpl.java @@ -3,9 +3,12 @@ import java.util.Optional; import java.util.UUID; +import org.folio.client.CirculationClient; import org.folio.domain.dto.EcsTlr; +import org.folio.domain.dto.Request; import org.folio.domain.mapper.EcsTlrMapper; import org.folio.repository.EcsTlrRepository; +import org.folio.service.TenantScopedExecutionService; import org.folio.service.EcsTlrService; import org.springframework.stereotype.Service; @@ -19,6 +22,8 @@ public class EcsTlrServiceImpl implements EcsTlrService { private final EcsTlrRepository ecsTlrRepository; private final EcsTlrMapper requestsMapper; + private final CirculationClient circulationClient; + private final TenantScopedExecutionService tenantScopedExecutionService; @Override public Optional get(UUID id) { @@ -31,8 +36,20 @@ public Optional get(UUID id) { @Override public EcsTlr post(EcsTlr ecsTlr) { log.debug("post:: parameters ecsTlr: {}", () -> ecsTlr); + createRemoteRequest(ecsTlr, "university"); // TODO: replace with real tenantId return requestsMapper.mapEntityToDto(ecsTlrRepository.save( requestsMapper.mapDtoToEntity(ecsTlr))); } + + private Request createRemoteRequest(EcsTlr ecsTlr, String tenantId) { + log.info("createRemoteRequest:: creating remote request for ECS TLR {} and tenant {}", ecsTlr.getId(), tenantId); + Request mappedRequest = requestsMapper.mapDtoToRequest(ecsTlr); + Request createdRequest = tenantScopedExecutionService.execute(tenantId, + () -> circulationClient.createRequest(mappedRequest)); + log.info("createRemoteRequest:: request created: {}", createdRequest.getId()); + log.debug("createRemoteRequest:: request: {}", () -> createdRequest); + + return createdRequest; + } } diff --git a/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java new file mode 100644 index 00000000..5d871d98 --- /dev/null +++ b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java @@ -0,0 +1,40 @@ +package org.folio.service.impl; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +import org.folio.exception.TenantScopedExecutionException; +import org.folio.service.TenantScopedExecutionService; +import org.folio.spring.FolioExecutionContext; +import org.folio.spring.FolioModuleMetadata; +import org.folio.spring.integration.XOkapiHeaders; +import org.folio.spring.scope.FolioExecutionContextSetter; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class TenantScopedExecutionServiceImpl implements TenantScopedExecutionService { + + private final FolioModuleMetadata moduleMetadata; + private final FolioExecutionContext executionContext; + + @Override + public T execute(String tenantId, Callable action) { + log.info("execute:: tenantId: {}", tenantId); + Map> headers = executionContext.getAllHeaders(); + headers.put(XOkapiHeaders.TENANT, List.of(tenantId)); + + try (var x = new FolioExecutionContextSetter(moduleMetadata, headers)) { + return action.call(); + } catch (Exception e) { + log.error("execute:: tenantId: {}", tenantId, e); + throw new TenantScopedExecutionException(e, tenantId); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5fa89cd8..c3fef02e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -43,6 +43,10 @@ folio: enabled: true environment: ${ENV:folio} okapi-url: ${OKAPI_URL:http://okapi:9130} + logging: + feign: + enabled: true + level: full management: endpoints: web: diff --git a/src/main/resources/db/changelog/changes/initial_schema.xml b/src/main/resources/db/changelog/changes/initial_schema.xml index b8aa6165..d0ea0ae6 100644 --- a/src/main/resources/db/changelog/changes/initial_schema.xml +++ b/src/main/resources/db/changelog/changes/initial_schema.xml @@ -17,6 +17,7 @@ + diff --git a/src/main/resources/log4j2.properties b/src/main/resources/log4j2.properties new file mode 100644 index 00000000..6fbbd253 --- /dev/null +++ b/src/main/resources/log4j2.properties @@ -0,0 +1,15 @@ +status = error +name = PropertiesConfig +packages = org.folio.spring.logging + +appenders = console + +appender.console.type = Console +appender.console.name = STDOUT + +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{HH:mm:ss} [$${folio:requestid:-}] [$${folio:tenantid:-}] [$${folio:userid:-}] [$${folio:moduleid:-}] %-5p %-20.20C{1} %m%n + +rootLogger.level = debug +rootLogger.appenderRefs = debug +rootLogger.appenderRef.stdout.ref = STDOUT \ No newline at end of file diff --git a/src/main/resources/swagger.api/ecs-tlr.yaml b/src/main/resources/swagger.api/ecs-tlr.yaml index 3a2064a0..657e0354 100644 --- a/src/main/resources/swagger.api/ecs-tlr.yaml +++ b/src/main/resources/swagger.api/ecs-tlr.yaml @@ -45,7 +45,9 @@ components: ecs-tlr: $ref: 'schemas/EcsTlr.yaml#/EcsTlr' errorResponse: - $ref: schemas/errors.json + $ref: 'schemas/errors.json' + request: + $ref: 'schemas/request.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 ca60b510..3405bf5e 100644 --- a/src/main/resources/swagger.api/schemas/EcsTlr.yaml +++ b/src/main/resources/swagger.api/schemas/EcsTlr.yaml @@ -23,6 +23,10 @@ EcsTlr: description: "Date when the request expires" type: string format: date-time + requestDate: + description: "Date when the request was placed" + type: string + format: date-time patronComments: description: "Comments made by the patron" type: string @@ -42,3 +46,4 @@ EcsTlr: - requestType - requestLevel - fulfillmentPreference + - requestDate diff --git a/src/main/resources/swagger.api/schemas/override-blocks.json b/src/main/resources/swagger.api/schemas/override-blocks.json new file mode 100644 index 00000000..51985abd --- /dev/null +++ b/src/main/resources/swagger.api/schemas/override-blocks.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Blocks to override (e.g. during checkout or renewal)", + "properties": { + "itemNotLoanableBlock": { + "description": "'Item not loanable' block", + "type": "object", + "properties": { + "dueDate": { + "description": "Due date for a new loan", + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false, + "required": [ + "dueDate" + ] + }, + "patronBlock": { + "description": "Automated patron block", + "type": "object", + "additionalProperties": false + }, + "itemLimitBlock": { + "description": "Item limit block", + "type": "object", + "additionalProperties": false + }, + "renewalBlock": { + "description": "Renewal block", + "type": "object", + "additionalProperties": false + }, + "renewalDueDateRequiredBlock": { + "description": "Override renewal block which requires due date field", + "type": "object", + "properties": { + "dueDate": { + "description": "Due date for a new loan", + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false, + "required": [ + "dueDate" + ] + }, + "comment": { + "description": "Reason for override", + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/src/main/resources/swagger.api/schemas/request-search-index.json b/src/main/resources/swagger.api/schemas/request-search-index.json new file mode 100644 index 00000000..25ff8c84 --- /dev/null +++ b/src/main/resources/swagger.api/schemas/request-search-index.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request fields used for search", + "type": "object", + "properties": { + "callNumberComponents": { + "type": "object", + "description": "Effective call number components", + "properties": { + "callNumber": { + "type": "string", + "description": "Effective Call Number is an identifier assigned to an item or its holding and associated with the item." + }, + "prefix": { + "type": "string", + "description": "Effective Call Number Prefix is the prefix of the identifier assigned to an item or its holding and associated with the item." + }, + "suffix": { + "type": "string", + "description": "Effective Call Number Suffix is the suffix of the identifier assigned to an item or its holding and associated with the item." + } + }, + "additionalProperties": false + }, + "shelvingOrder": { + "type": "string", + "description": "A system generated normalization of the call number that allows for call number sorting in reports and search results" + }, + "pickupServicePointName": { + "description": "The name of the request pickup service point", + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/src/main/resources/swagger.api/schemas/request.json b/src/main/resources/swagger.api/schemas/request.json new file mode 100644 index 00000000..cb43c1f3 --- /dev/null +++ b/src/main/resources/swagger.api/schemas/request.json @@ -0,0 +1,395 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "A request for an item", + "description": "Request for an item that might be at a different location or already checked out to another patron", + "type": "object", + "properties": { + "id": { + "description": "UUID of the request", + "type": "string", + "$ref": "uuid.json" + }, + "requestType": { + "description": "Whether the item should be held upon return, recalled or paged for", + "type": "string", + "enum": ["Hold", "Recall", "Page"] + }, + "requestLevel": { + "description": "Level of the request - Item or Title", + "type": "string", + "enum": ["Item", "Title"] + }, + "requestDate": { + "description": "Date the request was made", + "type": "string", + "format": "date-time" + }, + "patronComments": { + "description": "Comments made by the patron", + "type": "string" + }, + "requesterId": { + "description": "ID of the user who made the request", + "type": "string", + "$ref": "uuid.json" + }, + "proxyUserId": { + "description": "ID of the user representing a proxy for the patron", + "type": "string", + "$ref": "uuid.json" + }, + "instanceId": { + "description": "ID of the instance being requested", + "type": "string", + "$ref": "uuid.json" + }, + "holdingsRecordId": { + "description": "ID of the holdings record being requested", + "type": "string", + "$ref": "uuid.json" + }, + "itemId": { + "description": "ID of the item being requested", + "type": "string", + "$ref": "uuid.json" + }, + "status": { + "description": "Status of the request", + "type": "string", + "enum": [ + "Open - Not yet filled", + "Open - Awaiting pickup", + "Open - In transit", + "Open - Awaiting delivery", + "Closed - Filled", + "Closed - Cancelled", + "Closed - Unfilled", + "Closed - Pickup expired" + ] + }, + "cancellationReasonId": { + "description": "The id of the request reason", + "type": "string", + "$ref": "uuid.json" + }, + "cancelledByUserId": { + "description": "The id of the user that cancelled the request", + "type": "string", + "$ref": "uuid.json" + }, + "cancellationAdditionalInformation": { + "description": "Additional information about a cancellation", + "type": "string" + }, + "cancelledDate": { + "description": "Date the request was cancelled", + "type": "string", + "format": "date-time" + }, + "position": { + "description": "position of the request in a per-item request queue", + "type": "integer", + "minimum": 1 + }, + "instance": { + "description": "Copy of some instance metadata (used for searching and sorting)", + "type": "object", + "properties": { + "title": { + "description": "title of the item", + "type": "string" + }, + "identifiers": { + "type": "array", + "description": "An extensible set of name-value pairs of identifiers associated with the resource", + "minItems": 0, + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Resource identifier value" + }, + "identifierTypeId": { + "type": "string", + "description": "UUID of resource identifier type (e.g. ISBN, ISSN, LCCN, CODEN, Locally defined identifiers)", + "$ref": "uuid.json" + } + }, + "additionalProperties": false, + "required": [ + "value", + "identifierTypeId" + ] + } + } + } + }, + "item": { + "description": "Copy of some item metadata (used for searching and sorting)", + "type": "object", + "properties": { + "barcode": { + "description": "barcode of the item", + "type": "string" + } + }, + "additionalProperties": false + }, + "requester": { + "description": "Copy of some requesting patron metadata (used for searching and sorting), will be taken from the user referred to by the requesterId", + "readonly": true, + "type": "object", + "properties": { + "firstName": { + "description": "first name of the patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "lastName": { + "description": "last name of the patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "middleName": { + "description": "middle name of the patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "barcode": { + "description": "barcode of the patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "patronGroupId": { + "description": "UUID for the patron group that this user belongs to", + "type": "string", + "readonly": true, + "$ref": "uuid.json" + }, + "patronGroup": { + "description": "record for the user's patron group", + "type": "object", + "additionalProperties": false, + "readonly": true, + "properties": { + "id": { + "description": "ID of the patron group", + "type": "string", + "readonly": true, + "$ref": "uuid.json" + }, + "group": { + "description": "The unique name of the patron group", + "type": "string", + "readonly": true + }, + "desc": { + "description": "A description of the patron group", + "type": "string", + "readonly": true + } + } + } + }, + "additionalProperties": false + }, + "proxy": { + "description": "Copy of some proxy patron metadata (used for searching and sorting), will be taken from the user referred to by the proxyUserId", + "readonly": true, + "type": "object", + "properties": { + "firstName": { + "description": "first name of the proxy patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "lastName": { + "description": "last name of the proxy patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "middleName": { + "description": "middle name of the proxy patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "barcode": { + "description": "barcode of the proxy patron (read only, defined by the server)", + "type": "string", + "readonly": true + }, + "patronGroupId": { + "description": "UUID for the patrongroup that this user belongs to", + "type": "string", + "readonly": true, + "$ref": "uuid.json" + }, + "patronGroup": { + "description": "record for the user's patrongroup", + "type": "object", + "readonly": true, + "additionalProperties": false, + "properties": { + "id": { + "description": "ID of the patrongroup", + "type": "string", + "readonly": true, + "$ref": "uuid.json" + }, + "group": { + "description": "The unique name of the patrongroup", + "type": "string", + "readonly": true + }, + "desc": { + "description": "A description of the patrongroup", + "type": "string", + "readonly": true + } + } + } + }, + "additionalProperties": false + }, + "fulfillmentPreference": { + "description": "How should the request be fulfilled (whether the item should be kept on the hold shelf for collection or delivered to the requester)", + "type": "string", + "enum": ["Hold Shelf", "Delivery"] + }, + "deliveryAddressTypeId": { + "description": "Deliver to the address of this type, for the requesting patron", + "type": "string", + "$ref": "uuid.json" + }, + "deliveryAddress": { + "description": "Address the item is to be delivered to (derived from requester information)", + "type": "object", + "readonly": true, + "properties": { + "addressLine1": { + "description": "Address line 1", + "type": "string", + "readonly": true + }, + "addressLine2": { + "description": "Address line 2", + "type": "string", + "readonly": true + }, + "city": { + "description": "City name", + "type": "string", + "readonly": true + }, + "region": { + "description": "Region", + "type": "string", + "readonly": true + }, + "postalCode": { + "description": "Postal code", + "type": "string", + "readonly": true + }, + "countryId": { + "description": "Country code", + "type": "string", + "readonly": true + }, + "addressTypeId": { + "description": "Type of address (refers to address types)", + "type": "string", + "readonly": true, + "$ref": "uuid.json" + } + }, + "additionalProperties": false + }, + "requestExpirationDate": { + "description": "Date when the request expires", + "type": "string", + "format": "date-time" + }, + "holdShelfExpirationDate": { + "description": "Date when an item returned to the hold shelf expires", + "type": "string", + "format": "date-time" + }, + "pickupServicePointId": { + "description": "The ID of the Service Point where this request can be picked up", + "type": "string", + "$ref": "uuid.json" + }, + "pickupServicePoint": { + "description": "The full object of the Service Point record from pickupServicePointId", + "additionalProperties": false, + "readonly": true, + "properties": { + "name": { + "description": "Unique name for the service point", + "type": "string", + "readonly": true + }, + "code": { + "description": "Unique code for the service point", + "type": "string", + "readonly": true + }, + "discoveryDisplayName": { + "description": "Human-readable name for the service point", + "type": "string", + "readonly": true + }, + "description": { + "description": "Description of the service point", + "type": "string", + "readonly": true + }, + "shelvingLagTime": { + "description": "Shelving lag time", + "type": "integer", + "readonly": true + }, + "pickupLocation": { + "description": "Is this service point a pickup location?", + "type": "boolean", + "readonly": true + } + } + }, + "tags": { + "type": "object", + "description": "Tags", + "$ref": "tags.json" + }, + "metadata": { + "description": "Metadata about creation and changes to requests, provided by the server (client should not provide)", + "type": "object", + "$ref": "metadata.json" + }, + "requestProcessingParameters": { + "type": "object", + "description": "Additional parameters used for request processing and discarded afterwards. Not part of request record.", + "properties": { + "overrideBlocks": { + "type": "object", + "description": "Blocks to override if user has corresponding permissions", + "$ref": "override-blocks.json" + } + } + }, + "searchIndex": { + "description": "Request fields used for search", + "type": "object", + "$ref": "request-search-index.json" + } + }, + "additionalProperties": false, + "required": [ + "requesterId", + "requestType", + "requestDate", + "fulfillmentPreference" + ] +} diff --git a/src/main/resources/swagger.api/schemas/tags.json b/src/main/resources/swagger.api/schemas/tags.json new file mode 100644 index 00000000..cc7cfcfc --- /dev/null +++ b/src/main/resources/swagger.api/schemas/tags.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "tags", + "description": "List of simple tags that can be added to an object", + "type": "object", + "properties": { + "tagList": { + "description": "List of tags", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/src/main/resources/swagger.api/schemas/uuid.json b/src/main/resources/swagger.api/schemas/uuid.json new file mode 100644 index 00000000..1f907f9e --- /dev/null +++ b/src/main/resources/swagger.api/schemas/uuid.json @@ -0,0 +1,5 @@ +{ + "description": "A universally unique identifier (UUID), this is a 128-bit number used to identify a record and is shown in hex with dashes, for example 6312d172-f0cf-40f6-b27d-9fa8feaf332f; the UUID version must be from 1-5; see https://dev.folio.org/guides/uuids/", + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" +} diff --git a/src/test/java/org/folio/api/BaseIT.java b/src/test/java/org/folio/api/BaseIT.java index e34adfc0..73e1103a 100644 --- a/src/test/java/org/folio/api/BaseIT.java +++ b/src/test/java/org/folio/api/BaseIT.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import com.github.tomakehurst.wiremock.WireMockServer; import lombok.SneakyThrows; import org.folio.spring.integration.XOkapiHeaders; @@ -14,20 +15,25 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.support.TestPropertySourceUtils; import org.springframework.test.util.TestSocketUtils; +import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.web.reactive.function.BodyInserters; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Testcontainers; import java.util.List; +import java.util.UUID; import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -38,14 +44,18 @@ public class BaseIT { @Autowired protected MockMvc mockMvc; - public static WireMockServer wireMockServer; + protected static WireMockServer wireMockServer; protected static final String TOKEN = "test_token"; - public static final String TENANT = "diku"; - protected static PostgreSQLContainer postgresDBContainer = new PostgreSQLContainer<>("postgres:12-alpine"); - public final static int WIRE_MOCK_PORT = TestSocketUtils.findAvailableTcpPort(); - protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL) + protected static final String TENANT = "diku"; + private static final PostgreSQLContainer postgresDBContainer = new PostgreSQLContainer<>("postgres:12-alpine"); + private static final int WIRE_MOCK_PORT = TestSocketUtils.findAvailableTcpPort(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + @LocalServerPort + private int serverPort; + private WebTestClient webClient; static { postgresDBContainer.start(); @@ -69,7 +79,7 @@ static void tearDown() { @SneakyThrows protected static void setUpTenant(MockMvc mockMvc) { - mockMvc.perform(post("/_/tenant") + mockMvc.perform(MockMvcRequestBuilders.post("/_/tenant") .content(asJsonString(new TenantAttributes().moduleTo("mod-tlr"))) .headers(defaultHeaders()) .contentType(APPLICATION_JSON)).andExpect(status().isNoContent()); @@ -102,4 +112,33 @@ public void initialize(@NotNull ConfigurableApplicationContext applicationContex } } + protected WebTestClient webClient() { + if (webClient == null) { + webClient = WebTestClient.bindToServer() + .baseUrl("http://localhost:" + serverPort).build(); + } + return webClient; + } + + protected WebTestClient.RequestBodySpec buildRequest(HttpMethod method, String uri) { + return webClient().method(method) + .uri(uri) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(XOkapiHeaders.TENANT, TENANT) + .header(XOkapiHeaders.URL, wireMockServer.baseUrl()) + .header(XOkapiHeaders.TOKEN, TOKEN) + .header(XOkapiHeaders.USER_ID, randomId()); + } + + protected WebTestClient.ResponseSpec doPost(String url, Object payload) { + return buildRequest(HttpMethod.POST, url) + .body(BodyInserters.fromValue(payload)) + .exchange(); + } + + protected static String randomId() { + return UUID.randomUUID().toString(); + } + } diff --git a/src/test/java/org/folio/api/EcsTlrApiTest.java b/src/test/java/org/folio/api/EcsTlrApiTest.java index 71e8e898..0aa3fecc 100644 --- a/src/test/java/org/folio/api/EcsTlrApiTest.java +++ b/src/test/java/org/folio/api/EcsTlrApiTest.java @@ -1,20 +1,112 @@ package org.folio.api; +import org.apache.http.HttpStatus; +import org.folio.domain.dto.EcsTlr; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.MediaType; + +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; + +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.jsonResponse; +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; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.github.tomakehurst.wiremock.client.WireMock; + class EcsTlrApiTest extends BaseIT { - private static final String TLR_URL = "/tlr/ecs-tlr/"; + private static final String TLR_URL = "/tlr/ecs-tlr"; + private static final String ANOTHER_TENANT = "university"; + private static final String TENANT_HEADER = "x-okapi-tenant"; + + @Autowired + private FolioExecutionContext context; + @Autowired + private FolioModuleMetadata moduleMetadata; + @Autowired + private TestRestTemplate restTemplate; + private FolioExecutionContextSetter contextSetter; + + @BeforeEach + public void beforeEach() { + contextSetter = initContext(); + } + + @AfterEach + public void afterEach() { + contextSetter.close(); + } @Test void getByIdNotFound() throws Exception { mockMvc.perform( - get(TLR_URL + UUID.randomUUID()) + get(TLR_URL + "/" + UUID.randomUUID()) .headers(defaultHeaders()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); } + + @Test + void titleLevelRequestIsCreatedForDifferentTenant() { + EcsTlr ecsTlr = buildEcsTlr(); + String ecsTlrJson = asJsonString(ecsTlr); + wireMockServer.stubFor(WireMock.post(urlMatching(".*/circulation/requests")) + .withHeader(TENANT_HEADER, equalTo(ANOTHER_TENANT)) + .willReturn(jsonResponse(ecsTlrJson, HttpStatus.SC_CREATED))); + assertEquals(TENANT, getCurrentTenantId()); + + doPost(TLR_URL, ecsTlr) + .expectStatus().isCreated() + .expectBody().json(ecsTlrJson); + + assertEquals(TENANT, getCurrentTenantId()); + wireMockServer.verify(postRequestedFor(urlMatching(".*/circulation/requests")) + .withHeader(TENANT_HEADER, equalTo(ANOTHER_TENANT)) + .withRequestBody(equalToJson(ecsTlrJson))); + } + + private String getCurrentTenantId() { + return context.getTenantId(); + } + + private static Map> buildDefaultHeaders() { + return new HashMap<>(defaultHeaders().entrySet() + .stream() + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + + private FolioExecutionContextSetter initContext() { + return new FolioExecutionContextSetter(moduleMetadata, buildDefaultHeaders()); + } + + private static EcsTlr buildEcsTlr() { + return new EcsTlr() + .id(randomId()) + .itemId(randomId()) + .instanceId(randomId()) + .requesterId(randomId()) + .pickupServicePointId(randomId()) + .fulfillmentPreference(EcsTlr.FulfillmentPreferenceEnum.DELIVERY) + .patronComments("random comment") + .requestExpirationDate(new Date()) + .requestType(EcsTlr.RequestTypeEnum.PAGE) + .requestLevel(EcsTlr.RequestLevelEnum.TITLE); + } + } diff --git a/src/test/java/org/folio/controller/EcsTlrControllerTest.java b/src/test/java/org/folio/controller/EcsTlrControllerTest.java index f7f5021b..5d99b86e 100644 --- a/src/test/java/org/folio/controller/EcsTlrControllerTest.java +++ b/src/test/java/org/folio/controller/EcsTlrControllerTest.java @@ -5,7 +5,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.http.HttpStatus.CREATED; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import java.util.Optional; diff --git a/src/test/java/org/folio/controller/KafkaEventListenerTest.java b/src/test/java/org/folio/controller/KafkaEventListenerTest.java index a1df4c60..88184f33 100644 --- a/src/test/java/org/folio/controller/KafkaEventListenerTest.java +++ b/src/test/java/org/folio/controller/KafkaEventListenerTest.java @@ -93,7 +93,7 @@ private static void publishEvent(String topic, String payload) { private static void waitForOffset(String topic, String consumerGroupId, int expectedOffset) { Awaitility.await() - .atMost(30, TimeUnit.SECONDS) + .atMost(60, TimeUnit.SECONDS) .until(() -> getOffset(topic, consumerGroupId), offset -> offset.equals(expectedOffset)); } diff --git a/src/test/java/org/folio/service/EcsTlrServiceTest.java b/src/test/java/org/folio/service/EcsTlrServiceTest.java index 4eaf0752..f2904003 100644 --- a/src/test/java/org/folio/service/EcsTlrServiceTest.java +++ b/src/test/java/org/folio/service/EcsTlrServiceTest.java @@ -8,6 +8,7 @@ import java.util.UUID; 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.mapper.EcsTlrMapperImpl; @@ -28,6 +29,8 @@ class EcsTlrServiceTest { private EcsTlrServiceImpl ecsTlrService; @Mock private EcsTlrRepository ecsTlrRepository; + @Mock + private TenantScopedExecutionService tenantScopedExecutionService; @Spy private final EcsTlrMapper ecsTlrMapper = new EcsTlrMapperImpl(); @@ -46,7 +49,8 @@ void postEcsTlr() { var requestType = EcsTlr.RequestTypeEnum.PAGE; var requestLevel = EcsTlr.RequestLevelEnum.TITLE; var fulfillmentPreference = EcsTlr.FulfillmentPreferenceEnum.HOLD_SHELF; - var requestExpirationDate = DateTime.now().toDate(); + var requestExpirationDate = DateTime.now().plusDays(7).toDate(); + var requestDate = DateTime.now().toDate(); var patronComments = "Test comment"; var mockEcsTlrEntity = new EcsTlrEntity(); @@ -56,6 +60,7 @@ void postEcsTlr() { mockEcsTlrEntity.setRequestType(requestType.toString()); mockEcsTlrEntity.setRequestLevel(requestLevel.getValue()); mockEcsTlrEntity.setRequestExpirationDate(requestExpirationDate); + mockEcsTlrEntity.setRequestDate(requestDate); mockEcsTlrEntity.setPatronComments(patronComments); mockEcsTlrEntity.setFulfillmentPreference(fulfillmentPreference.getValue()); mockEcsTlrEntity.setPickupServicePointId(pickupServicePointId); @@ -67,11 +72,14 @@ void postEcsTlr() { ecsTlr.setRequestType(requestType); ecsTlr.setRequestLevel(requestLevel); ecsTlr.setRequestExpirationDate(requestExpirationDate); + ecsTlr.setRequestDate(requestDate); ecsTlr.setPatronComments(patronComments); ecsTlr.setFulfillmentPreference(fulfillmentPreference); ecsTlr.setPickupServicePointId(pickupServicePointId.toString()); when(ecsTlrRepository.save(any(EcsTlrEntity.class))).thenReturn(mockEcsTlrEntity); + when(tenantScopedExecutionService.execute(any(String.class), any())) + .thenReturn(new Request().id(UUID.randomUUID().toString())); var postEcsTlr = ecsTlrService.post(ecsTlr); assertEquals(id.toString(), postEcsTlr.getId()); @@ -79,6 +87,7 @@ void postEcsTlr() { assertEquals(requesterId.toString(), postEcsTlr.getRequesterId()); assertEquals(requestType, postEcsTlr.getRequestType()); assertEquals(requestExpirationDate, postEcsTlr.getRequestExpirationDate()); + assertEquals(requestDate, postEcsTlr.getRequestDate()); assertEquals(patronComments, postEcsTlr.getPatronComments()); assertEquals(fulfillmentPreference, postEcsTlr.getFulfillmentPreference()); assertEquals(pickupServicePointId.toString(), postEcsTlr.getPickupServicePointId()); diff --git a/src/test/java/org/folio/service/TenantScopedExecutionServiceTest.java b/src/test/java/org/folio/service/TenantScopedExecutionServiceTest.java new file mode 100644 index 00000000..937d3114 --- /dev/null +++ b/src/test/java/org/folio/service/TenantScopedExecutionServiceTest.java @@ -0,0 +1,44 @@ +package org.folio.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import java.util.HashMap; + +import org.folio.exception.TenantScopedExecutionException; +import org.folio.service.impl.TenantScopedExecutionServiceImpl; +import org.folio.spring.FolioExecutionContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TenantScopedExecutionServiceTest { + + @Mock + private FolioExecutionContext folioExecutionContext; + @InjectMocks + private TenantScopedExecutionServiceImpl executionService; + + @Test + void executionExceptionIsForwarded() { + when(folioExecutionContext.getAllHeaders()).thenReturn(new HashMap<>()); + String tenantId = "test-tenant"; + String errorMessage = "cause message"; + + TenantScopedExecutionException exception = assertThrows(TenantScopedExecutionException.class, + () -> executionService.execute(tenantId, () -> { + throw new IllegalAccessException(errorMessage); + })); + + assertEquals(tenantId, exception.getTenantId()); + assertNotNull(exception.getCause()); + assertInstanceOf(IllegalAccessException.class, exception.getCause()); + assertEquals(errorMessage, exception.getCause().getMessage()); + } +} \ No newline at end of file