diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 7d4581ed..fb89305c 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" + ] } ] }, 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..043dc9df --- /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 createTitleLevelRequest(Request request); +} diff --git a/src/main/java/org/folio/controller/EcsTlrController.java b/src/main/java/org/folio/controller/EcsTlrController.java index 9fe5e947..7f461ce5 100644 --- a/src/main/java/org/folio/controller/EcsTlrController.java +++ b/src/main/java/org/folio/controller/EcsTlrController.java @@ -34,6 +34,6 @@ public ResponseEntity getEcsTlrById(UUID requestId) { public ResponseEntity postEcsTlr(EcsTlr ecsTlr) { log.debug("postEcsTlr:: parameters ecsTlr: {}", ecsTlr); - return ResponseEntity.status(CREATED).body(ecsTlrService.post(ecsTlr)); + return ResponseEntity.status(CREATED).body(ecsTlrService.create(ecsTlr)); } } diff --git a/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java b/src/main/java/org/folio/domain/mapper/EcsTlrMapper.java index f4d97f9d..cb06de46 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; @@ -36,17 +37,19 @@ default EcsTlr.FulfillmentPreferenceEnum mapFulfillmentPreference(String fulfill } @Named("RequestTypeToString") - default String mapRequestTypeToString(EcsTlr.RequestTypeEnum requestTypeEnum) { - return requestTypeEnum != null ? requestTypeEnum.getValue() : null; + default String mapRequestType(EcsTlr.RequestTypeEnum requestType) { + return requestType != null ? requestType.getValue() : null; } @Named("RequestLevelToString") - default String mapRequestLevelToString(EcsTlr.RequestLevelEnum requestLevelEnum) { - return requestLevelEnum != null ? requestLevelEnum.getValue() : null; + default String mapRequestLevel(EcsTlr.RequestLevelEnum requestLevel) { + return requestLevel != null ? requestLevel.getValue() : null; } @Named("FulfillmentPreferenceToString") - default String mapFulfillmentPreferenceToString(EcsTlr.FulfillmentPreferenceEnum fulfillmentPreferenceEnum) { - return fulfillmentPreferenceEnum != null ? fulfillmentPreferenceEnum.getValue() : null; + default String mapFulfillmentPreference(EcsTlr.FulfillmentPreferenceEnum fulfillmentPreference) { + return fulfillmentPreference != null ? fulfillmentPreference.getValue() : null; } + + Request mapDtoToRequest(EcsTlr ecsTlr); } diff --git a/src/main/java/org/folio/service/EcsTlrService.java b/src/main/java/org/folio/service/EcsTlrService.java index 4d7da52a..bbee5bb8 100644 --- a/src/main/java/org/folio/service/EcsTlrService.java +++ b/src/main/java/org/folio/service/EcsTlrService.java @@ -7,5 +7,5 @@ public interface EcsTlrService { Optional get(UUID requestId); - EcsTlr post(EcsTlr ecsTlr); + EcsTlr create(EcsTlr ecsTlr); } 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..058312f4 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) { @@ -29,8 +34,12 @@ public Optional get(UUID id) { } @Override - public EcsTlr post(EcsTlr ecsTlr) { - log.debug("post:: parameters ecsTlr: {}", () -> ecsTlr); + public EcsTlr create(EcsTlr ecsTlr) { + log.debug("create:: parameters ecsTlr: {}", () -> ecsTlr); + Request mappedRequest = requestsMapper.mapDtoToRequest(ecsTlr); + Request cretedRequest = tenantScopedExecutionService.execute("dummy-tenant", // TODO: replace with real tenantId + () -> circulationClient.createTitleLevelRequest(mappedRequest)); + log.info("create:: title-level request created: {}", cretedRequest.getId()); return requestsMapper.mapEntityToDto(ecsTlrRepository.save( requestsMapper.mapDtoToEntity(ecsTlr))); 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..4248458b --- /dev/null +++ b/src/main/java/org/folio/service/impl/TenantScopedExecutionServiceImpl.java @@ -0,0 +1,39 @@ +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.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 RuntimeException(e); + } + } +} 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/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/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..c324081c 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, UUID.randomUUID().toString()); + } + + protected WebTestClient.ResponseSpec doPost(String uri, Object payload) { + return buildRequest(HttpMethod.POST, uri) + .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..c24dad22 100644 --- a/src/test/java/org/folio/api/EcsTlrApiTest.java +++ b/src/test/java/org/folio/api/EcsTlrApiTest.java @@ -1,20 +1,109 @@ 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.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 = "dummy-tenant"; + 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 + public void titleLevelRequestIsCreatedForDifferentTenant() { + EcsTlr ecsTlr = buildEcsTlr(); + wireMockServer.stubFor(WireMock.post(urlMatching(".*/circulation/requests")) + .withHeader(TENANT_HEADER, equalTo(ANOTHER_TENANT)) + .willReturn(jsonResponse(asJsonString(ecsTlr), HttpStatus.SC_CREATED))); + assertEquals(getCurrentTenantId(), TENANT); + + doPost(TLR_URL, ecsTlr) + .expectStatus().isCreated() + .expectBody().json(asJsonString(ecsTlr)); + + assertEquals(getCurrentTenantId(), TENANT); + wireMockServer.verify(postRequestedFor(urlMatching(".*/circulation/requests")) + .withHeader(TENANT_HEADER, equalTo(ANOTHER_TENANT))); + } + + 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..47e3477f 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; @@ -44,7 +43,7 @@ void getById() { @Test void ecsTlrShouldSuccessfullyBeCreated() { var mockRequest = new EcsTlr(); - when(requestsService.post(any(EcsTlr.class))).thenReturn(mockRequest); + when(requestsService.create(any(EcsTlr.class))).thenReturn(mockRequest); var response = requestsController.postEcsTlr(new EcsTlr()); diff --git a/src/test/java/org/folio/service/EcsTlrServiceTest.java b/src/test/java/org/folio/service/EcsTlrServiceTest.java index 4eaf0752..6c034c96 100644 --- a/src/test/java/org/folio/service/EcsTlrServiceTest.java +++ b/src/test/java/org/folio/service/EcsTlrServiceTest.java @@ -72,7 +72,7 @@ void postEcsTlr() { ecsTlr.setPickupServicePointId(pickupServicePointId.toString()); when(ecsTlrRepository.save(any(EcsTlrEntity.class))).thenReturn(mockEcsTlrEntity); - var postEcsTlr = ecsTlrService.post(ecsTlr); + var postEcsTlr = ecsTlrService.create(ecsTlr); assertEquals(id.toString(), postEcsTlr.getId()); assertEquals(instanceId.toString(), postEcsTlr.getInstanceId());