diff --git a/PERSONAL_DATA_DISCLOSURE.md b/PERSONAL_DATA_DISCLOSURE.md new file mode 100644 index 00000000..dd79bd27 --- /dev/null +++ b/PERSONAL_DATA_DISCLOSURE.md @@ -0,0 +1,58 @@ + + +## Overview +The purpose of this form is to disclose the types of personal data stored by each module. This information enables those hosting FOLIO to better manage and comply with various privacy laws and restrictions, e.g. GDPR. + +It's important to note that personal data is not limited to that which can be used to identify a person on it's own (e.g. Social security number), but also data used in conjunction with other data to identify a person (e.g. date of birth + city + gender). + +For the purposes of this form, "store" includes the following: +* Persisting to storage - Either internal (e.g. Postgres) or external (e.g. S3, etc.) to FOLIO +* Caching - In-memory, etc. +* Logging +* Sending to an external piece of infrastructure such as a queue (e.g. Kafka), search engine (e.g. Elasticsearch), distributed table, etc. + +## Personal Data Stored by This Module +- [ ] This module does not store any personal data. +- [ ] This module provides [custom fields](https://github.com/folio-org/folio-custom-fields). +- [ ] This module stores fields with free-form text (tags, notes, descriptions, etc.) +- [ ] This module caches personal data +--- +- [ ] First name +- [ ] Last name +- [ ] Middle name +- [x] Pseudonym / Alias / Nickname / Username / User ID +- [ ] Gender +- [ ] Date of birth +- [ ] Place of birth +- [ ] Racial or ethnic origin +- [ ] Address +- [ ] Location information +- [ ] Phone numbers +- [ ] Passport number / National identification numbers +- [ ] Driver’s license number +- [ ] Social security number +- [ ] Email address +- [ ] Web cookies +- [ ] IP address +- [ ] Geolocation data +- [ ] Financial information +- [ ] Logic or algorithms used to build a user/profile + + + +**NOTE** This is not intended to be a comprehensive list, but instead provide a starting point for module developers/maintainers to use. + +## Privacy Laws, Regulations, and Policies +The following laws and policies were considered when creating the list of personal data fields above. +* [General Data Protection Regulation (GDPR)](https://gdpr.eu/) +* [California Consumer Privacy Act (CCPA)](https://oag.ca.gov/privacy/ccpa) +* [U.S. Department of Labor: Guidance on the Protection of Personal Identifiable Information](https://www.dol.gov/general/ppii) +* Cybersecurity Law of the People's Republic of China + * https://www.newamerica.org/cybersecurity-initiative/digichina/blog/translation-cybersecurity-law-peoples-republic-china/ + * http://en.east-concord.com/zygd/Article/20203/ArticleContent_1690.html?utm_source=Mondaq&utm_medium=syndication&utm_campaign=LinkedIn-integration +* [Personal Data Protection Bill, 2019 (India)](https://www.prsindia.org/billtrack/personal-data-protection-bill-2019) +* [Data protection act 2018 (UK)](https://www.legislation.gov.uk/ukpga/2018/12/section/3/enacted) + +--- + +v1.0 diff --git a/README.md b/README.md index d3002ba6..3d883ff3 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,19 @@ requires and provides, the permissions, and the additional module metadata. ### Environment variables -| Name | Default value | Description | -|:-----------------------|:---------------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| DB_HOST | postgres | Postgres hostname | -| DB_PORT | 5432 | Postgres port | -| DB_USERNAME | folio_admin | Postgres username | -| DB_PASSWORD | - | Postgres username password | -| DB_DATABASE | okapi_modules | Postgres database name | -| KAFKA_HOST | kafka | Kafka broker hostname | -| KAFKA_PORT | 9092 | Kafka broker port | -| ENV | folio | Environment. Logical name of the deployment, must be set if Kafka/Elasticsearch are shared for environments, `a-z (any case)`, `0-9`, `-`, `_` symbols only allowed | -| SYSTEM\_USER\_NAME | dcb-system-user | Username of the system user | -| SYSTEM\_USER\_PASSWORD | - | Password of the system user | +| Name | Default value | Description | +|:-----------------------|:-------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| DB_HOST | postgres | Postgres hostname | +| DB_PORT | 5432 | Postgres port | +| DB_USERNAME | folio_admin | Postgres username | +| DB_PASSWORD | - | Postgres username password | +| DB_DATABASE | okapi_modules | Postgres database name | +| KAFKA_HOST | kafka | Kafka broker hostname | +| KAFKA_PORT | 9092 | Kafka broker port | +| ENV | folio | Environment. Logical name of the deployment, must be set if Kafka/Elasticsearch are shared for environments, `a-z (any case)`, `0-9`, `-`, `_` symbols only allowed | +| SYSTEM\_USER\_NAME | dcb-system-user | Username of the system user | +| SYSTEM\_USER\_PASSWORD | - | Password of the system user | +| ACTUATOR\_EXPOSURE | health,info,loggers | Back End Module Health Check Protocol | ## Additional information ### System user configuration diff --git a/src/main/java/org/folio/dcb/client/feign/CirculationItemClient.java b/src/main/java/org/folio/dcb/client/feign/CirculationItemClient.java index f2020e5f..2ce18e3c 100644 --- a/src/main/java/org/folio/dcb/client/feign/CirculationItemClient.java +++ b/src/main/java/org/folio/dcb/client/feign/CirculationItemClient.java @@ -1,22 +1,22 @@ package org.folio.dcb.client.feign; -import org.folio.dcb.domain.dto.CirculationItemRequest; +import org.folio.dcb.domain.dto.CirculationItem; +import org.folio.dcb.domain.dto.CirculationItemCollection; import org.folio.spring.config.FeignClientConfiguration; import org.springframework.cloud.openfeign.FeignClient; 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; @FeignClient(name = "circulation-item", configuration = FeignClientConfiguration.class) public interface CirculationItemClient { @PostMapping("/{circulationItemId}") - CirculationItemRequest createCirculationItem(@PathVariable("circulationItemId") String circulationItemId, @RequestBody CirculationItemRequest circulationRequest); + CirculationItem createCirculationItem(@PathVariable("circulationItemId") String circulationItemId, @RequestBody CirculationItem circulationRequest); @GetMapping("/{circulationItemId}") - CirculationItemRequest retrieveCirculationItemById(@PathVariable("circulationItemId") String circulationItemId); + CirculationItem retrieveCirculationItemById(@PathVariable("circulationItemId") String circulationItemId); - @PutMapping("/{circulationItemId}") - CirculationItemRequest updateCirculationItem(@PathVariable("circulationItemId") String circulationItemId, @RequestBody CirculationItemRequest circulationRequest); -} + @GetMapping("/items") + CirculationItemCollection fetchItemByIdAndBarcode(@RequestParam("query") String query);} diff --git a/src/main/java/org/folio/dcb/controller/ExceptionHandlingController.java b/src/main/java/org/folio/dcb/controller/ExceptionHandlingController.java index 4b917653..615c8231 100644 --- a/src/main/java/org/folio/dcb/controller/ExceptionHandlingController.java +++ b/src/main/java/org/folio/dcb/controller/ExceptionHandlingController.java @@ -34,15 +34,21 @@ public Errors handleGlobalException(Exception ex) { } @ResponseStatus(HttpStatus.NOT_FOUND) - @ExceptionHandler(NotFoundException.class) - public Errors handleNotFoundException(NotFoundException ex) { + @ExceptionHandler({ + NotFoundException.class, + FeignException.NotFound.class + }) + public Errors handleNotFoundException(Exception ex) { logExceptionMessage(ex); return createExternalError(ex.getMessage(), NOT_FOUND_ERROR); } @ResponseStatus(HttpStatus.CONFLICT) - @ExceptionHandler(ResourceAlreadyExistException.class) - public Errors handleAlreadyExistException(ResourceAlreadyExistException ex) { + @ExceptionHandler({ + ResourceAlreadyExistException.class, + FeignException.Conflict.class + }) + public Errors handleAlreadyExistException(Exception ex) { logExceptionMessage(ex); return createExternalError(ex.getMessage(), DUPLICATE_ERROR); } @@ -59,7 +65,8 @@ public Errors handleBadGatewayException(FeignException.BadGateway ex) { MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class, - IllegalArgumentException.class + IllegalArgumentException.class, + FeignException.BadRequest.class }) public Errors handleValidationErrors(Exception ex) { logExceptionMessage(ex); diff --git a/src/main/java/org/folio/dcb/controller/TransactionApiController.java b/src/main/java/org/folio/dcb/controller/TransactionApiController.java index de2b11b6..d455fefd 100644 --- a/src/main/java/org/folio/dcb/controller/TransactionApiController.java +++ b/src/main/java/org/folio/dcb/controller/TransactionApiController.java @@ -6,6 +6,7 @@ import org.folio.dcb.domain.dto.TransactionStatus; import org.folio.dcb.rest.resource.TransactionsApi; import org.folio.dcb.domain.dto.TransactionStatusResponse; +import org.folio.dcb.service.TransactionAuditService; import org.folio.dcb.service.TransactionsService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -17,24 +18,49 @@ public class TransactionApiController implements TransactionsApi { private final TransactionsService transactionsService; + private final TransactionAuditService transactionAuditService; @Override public ResponseEntity getTransactionStatusById(String dcbTransactionId) { log.info("getTransactionStatus:: by id {} ", dcbTransactionId); + TransactionStatusResponse transactionStatusResponse; + try { + transactionStatusResponse = transactionsService.getTransactionStatusById(dcbTransactionId); + } catch (Exception ex) { + transactionAuditService.logErrorIfTransactionAuditExists(dcbTransactionId, ex.getMessage()); + throw ex; + } + return ResponseEntity.status(HttpStatus.OK) - .body(transactionsService.getTransactionStatusById(dcbTransactionId)); + .body(transactionStatusResponse); } @Override public ResponseEntity createCirculationRequest(String dcbTransactionId, DcbTransaction dcbTransaction) { log.info("createCirculationRequest:: creating dcbTransaction {} with id {} ", dcbTransaction, dcbTransactionId); + TransactionStatusResponse transactionStatusResponse; + try { + transactionStatusResponse = transactionsService.createCirculationRequest(dcbTransactionId, dcbTransaction); + } catch (Exception ex) { + transactionAuditService.logErrorIfTransactionAuditNotExists(dcbTransactionId, dcbTransaction, ex.getMessage()); + throw ex; + } + return ResponseEntity.status(HttpStatus.CREATED) - .body(transactionsService.createCirculationRequest(dcbTransactionId, dcbTransaction)); + .body(transactionStatusResponse); } @Override public ResponseEntity updateTransactionStatus(String dcbTransactionId, TransactionStatus transactionStatus) { log.info("updateTransactionStatus:: updating dcbTransaction with id {} to status {} ", dcbTransactionId, transactionStatus.getStatus()); + TransactionStatusResponse transactionStatusResponse; + try { + transactionStatusResponse = transactionsService.updateTransactionStatus(dcbTransactionId, transactionStatus); + } catch (Exception ex) { + transactionAuditService.logErrorIfTransactionAuditExists(dcbTransactionId, ex.getMessage()); + throw ex; + } + return ResponseEntity.status(HttpStatus.OK) - .body(transactionsService.updateTransactionStatus(dcbTransactionId, transactionStatus)); + .body(transactionStatusResponse); } } diff --git a/src/main/java/org/folio/dcb/domain/entity/TransactionAuditEntity.java b/src/main/java/org/folio/dcb/domain/entity/TransactionAuditEntity.java index b5c9fd16..cae07222 100644 --- a/src/main/java/org/folio/dcb/domain/entity/TransactionAuditEntity.java +++ b/src/main/java/org/folio/dcb/domain/entity/TransactionAuditEntity.java @@ -36,6 +36,7 @@ public class TransactionAuditEntity extends TransactionAuditableEntity { @ColumnTransformer(write = "?::jsonb") @Column(columnDefinition = "jsonb") private String after; + private String errorMessage; private String transactionId; } diff --git a/src/main/java/org/folio/dcb/domain/entity/TransactionEntity.java b/src/main/java/org/folio/dcb/domain/entity/TransactionEntity.java index f0a979d2..71e688b7 100644 --- a/src/main/java/org/folio/dcb/domain/entity/TransactionEntity.java +++ b/src/main/java/org/folio/dcb/domain/entity/TransactionEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -19,6 +20,7 @@ import org.folio.dcb.domain.dto.TransactionStatus.StatusEnum; import org.folio.dcb.listener.entity.TransactionAuditEntityListener; +import java.io.Serializable; import java.util.UUID; @Entity @@ -30,7 +32,7 @@ @AllArgsConstructor @NoArgsConstructor @Builder -public class TransactionEntity extends AuditableEntity { +public class TransactionEntity extends AuditableEntity implements Serializable { @Id private String id; @Convert(converter = UUIDConverter.class) @@ -53,6 +55,7 @@ public class TransactionEntity extends AuditableEntity { private StatusEnum status; @Enumerated(EnumType.STRING) private DcbTransaction.RoleEnum role; - + @Transient + protected TransactionEntity savedState; } diff --git a/src/main/java/org/folio/dcb/listener/entity/TransactionAuditEntityListener.java b/src/main/java/org/folio/dcb/listener/entity/TransactionAuditEntityListener.java index 9aa474f4..173dfce8 100644 --- a/src/main/java/org/folio/dcb/listener/entity/TransactionAuditEntityListener.java +++ b/src/main/java/org/folio/dcb/listener/entity/TransactionAuditEntityListener.java @@ -3,32 +3,26 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityManager; -import jakarta.persistence.PostPersist; +import jakarta.persistence.PostLoad; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import lombok.extern.log4j.Log4j2; +import org.apache.commons.lang3.SerializationUtils; import org.folio.dcb.domain.entity.TransactionAuditEntity; import org.folio.dcb.domain.entity.TransactionEntity; -import org.folio.dcb.service.impl.TransactionsServiceImpl; import org.folio.dcb.utils.BeanUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - @Component @Log4j2 public class TransactionAuditEntityListener { private static final String CREATE_ACTION = "CREATE"; private static final String UPDATE_ACTION = "UPDATE"; - @Autowired private BeanUtil beanUtil; @Autowired private ObjectMapper objectMapper; - private static final Map originalStateCache = new HashMap<>(); @PrePersist public void onPrePersist(Object entity) throws JsonProcessingException { @@ -44,37 +38,25 @@ public void onPrePersist(Object entity) throws JsonProcessingException { getEntityManager().persist(transactionAuditEntity); } - @PostPersist - public void onPostPersist(Object entity) { - TransactionEntity transactionEntity = (TransactionEntity) entity; - originalStateCache.put(transactionEntity.getId(), transactionEntity); - } - @PreUpdate public void onPreUpdate(Object entity) throws JsonProcessingException { log.debug("onPreUpdate:: creating transaction audit record"); TransactionEntity transactionEntity = (TransactionEntity) entity; TransactionAuditEntity transactionAuditEntity = new TransactionAuditEntity(); - transactionAuditEntity.setBefore(objectMapper.writeValueAsString(getTransactionEntity(transactionEntity.getId()))); + transactionAuditEntity.setBefore(objectMapper.writeValueAsString(transactionEntity.getSavedState())); transactionAuditEntity.setAfter(objectMapper.writeValueAsString(transactionEntity)); transactionAuditEntity.setTransactionId(transactionEntity.getId()); transactionAuditEntity.setAction(UPDATE_ACTION); log.info("onPreUpdate:: creating transaction audit record {} with action {}", transactionEntity.getId(), UPDATE_ACTION); getEntityManager().persist(transactionAuditEntity); - originalStateCache.put(transactionEntity.getId(), transactionEntity); } - private TransactionEntity getTransactionEntity(String transactionId) { - // Try to get the original state from the cache - TransactionEntity originalState = originalStateCache.get(transactionId); - - // If not found, fetch it from the database - if (Objects.isNull(originalState)) { - originalState = getTransactionsService().getTransactionEntityOrThrow(transactionId); - originalStateCache.put(transactionId, originalState); - } - return originalState; + //This method will be invoked when the transactionEntity is loaded and the transactionEntity is stored in a transient field + //The stored value will be used in onPreUpdate method's setBefore method. + @PostLoad + public void saveState(TransactionEntity transactionEntity){ + transactionEntity.setSavedState(SerializationUtils.clone((transactionEntity))); } //EntityListeners are instantiated by JPA, not Spring, @@ -83,7 +65,4 @@ private EntityManager getEntityManager() { return beanUtil.getBean(EntityManager.class); } - private TransactionsServiceImpl getTransactionsService() { - return beanUtil.getBean(TransactionsServiceImpl.class); - } } diff --git a/src/main/java/org/folio/dcb/listener/kafka/CirculationEventListener.java b/src/main/java/org/folio/dcb/listener/kafka/CirculationEventListener.java index b116de58..84596477 100644 --- a/src/main/java/org/folio/dcb/listener/kafka/CirculationEventListener.java +++ b/src/main/java/org/folio/dcb/listener/kafka/CirculationEventListener.java @@ -15,8 +15,12 @@ import java.util.Objects; import java.util.UUID; +import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.BORROWING_PICKUP; +import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.LENDER; +import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.PICKUP; import static org.folio.dcb.utils.TransactionHelper.getHeaderValue; -import static org.folio.dcb.utils.TransactionHelper.parseEvent; +import static org.folio.dcb.utils.TransactionHelper.parseLoanEvent; +import static org.folio.dcb.utils.TransactionHelper.parseRequestEvent; @Log4j2 @Component @@ -36,53 +40,37 @@ public class CirculationEventListener { private final SystemUserScopedExecutionService systemUserScopedExecutionService; private final BaseLibraryService baseLibraryService; - @KafkaListener( - id = CHECK_IN_LISTENER_ID, - topicPattern = "#{folioKafkaProperties.listener['check-in'].topicPattern}", - concurrency = "#{folioKafkaProperties.listener['check-in'].concurrency}") - public void handleCheckInEvent(String data, MessageHeaders messageHeaders) { - String tenantId = getHeaderValue(messageHeaders, XOkapiHeaders.TENANT, null).get(0); - var eventData = parseEvent(data); - if (Objects.nonNull(eventData) && eventData.getType() == EventData.EventType.CHECK_IN) { - String checkInItemId = eventData.getItemId(); - if (Objects.nonNull(checkInItemId)) { - log.info("updateTransactionStatus:: Received checkIn event for itemId: {}", checkInItemId); - systemUserScopedExecutionService.executeAsyncSystemUserScoped(tenantId, () -> - transactionRepository.findTransactionByItemIdAndStatusNotInClosed(UUID.fromString(checkInItemId)) - .ifPresent(transactionEntity -> { - switch (transactionEntity.getRole()) { - case LENDER -> lendingLibraryService.updateStatusByTransactionEntity(transactionEntity); - case BORROWING_PICKUP -> borrowingLibraryService.updateStatusByTransactionEntity(transactionEntity); - case PICKUP -> pickupLibraryService.updateStatusByTransactionEntity(transactionEntity); - default -> throw new IllegalArgumentException("Other roles are not implemented yet"); - } - }) - ); - } - } - } - @KafkaListener( id = CHECK_OUT_LOAN_LISTENER_ID, topicPattern = "#{folioKafkaProperties.listener['loan'].topicPattern}", concurrency = "#{folioKafkaProperties.listener['loan'].concurrency}") - public void handleCheckOutEvent(String data, MessageHeaders messageHeaders) { + public void handleLoanEvent(String data, MessageHeaders messageHeaders) { String tenantId = getHeaderValue(messageHeaders, XOkapiHeaders.TENANT, null).get(0); - var eventData = parseEvent(data); - if (Objects.nonNull(eventData) && eventData.getType() == EventData.EventType.CHECK_OUT) { - String checkOutItemId = eventData.getItemId(); - if (Objects.nonNull(checkOutItemId)) { - log.info("updateTransactionStatus:: Received checkOut event for itemId: {}", checkOutItemId); - systemUserScopedExecutionService.executeAsyncSystemUserScoped(tenantId, () -> - transactionRepository.findTransactionByItemIdAndStatusNotInClosed(UUID.fromString(checkOutItemId)) - .ifPresent(transactionEntity -> { - switch (transactionEntity.getRole()) { - case BORROWING_PICKUP -> borrowingLibraryService.updateStatusByTransactionEntity(transactionEntity); - case PICKUP -> pickupLibraryService.updateStatusByTransactionEntity(transactionEntity); - default -> throw new IllegalArgumentException("Other roles are not implemented yet"); - } - }) - ); + var eventData = parseLoanEvent(data); + if (Objects.nonNull(eventData)) { + String itemId = eventData.getItemId(); + if (Objects.nonNull(itemId)) { + log.info("updateTransactionStatus:: Received checkOut event for itemId: {}", itemId); + systemUserScopedExecutionService.executeAsyncSystemUserScoped(tenantId, () -> + transactionRepository.findTransactionByItemIdAndStatusNotInClosed(UUID.fromString(itemId)) + .ifPresent(transactionEntity -> { + if(eventData.getType() == EventData.EventType.CHECK_OUT) { + if(transactionEntity.getRole() == BORROWING_PICKUP) { + borrowingLibraryService.updateStatusByTransactionEntity(transactionEntity); + } else if(transactionEntity.getRole() == PICKUP) { + pickupLibraryService.updateStatusByTransactionEntity(transactionEntity); + } + } else if(eventData.getType() == EventData.EventType.CHECK_IN) { + if(transactionEntity.getRole() == LENDER) { + lendingLibraryService.updateStatusByTransactionEntity(transactionEntity); + } else if(transactionEntity.getRole() == BORROWING_PICKUP) { + borrowingLibraryService.updateStatusByTransactionEntity(transactionEntity); + } else if(transactionEntity.getRole() == PICKUP) { + pickupLibraryService.updateStatusByTransactionEntity(transactionEntity); + } + } + }) + ); } } } @@ -91,19 +79,30 @@ public void handleCheckOutEvent(String data, MessageHeaders messageHeaders) { id = REQUEST_LISTENER_ID, topicPattern = "#{folioKafkaProperties.listener['request'].topicPattern}", concurrency = "#{folioKafkaProperties.listener['request'].concurrency}") - public void handleRequestCancelEvent(String data, MessageHeaders messageHeaders) { + public void handleRequestEvent(String data, MessageHeaders messageHeaders) { String tenantId = getHeaderValue(messageHeaders, XOkapiHeaders.TENANT, null).get(0); - var eventData = parseEvent(data); - if (Objects.nonNull(eventData) && eventData.getType() == EventData.EventType.CANCEL) { + var eventData = parseRequestEvent(data); + if (Objects.nonNull(eventData)) { String requestId = eventData.getRequestId(); if (Objects.nonNull(requestId)) { log.info("updateTransactionStatus:: Received cancel event for requestId: {}", requestId); systemUserScopedExecutionService.executeAsyncSystemUserScoped(tenantId, () -> transactionRepository.findTransactionByRequestIdAndStatusNotInClosed(UUID.fromString(requestId)) - .ifPresent(baseLibraryService::cancelTransactionEntity) + .ifPresent(transactionEntity -> { + if(eventData.getType() == EventData.EventType.CANCEL) { + baseLibraryService.cancelTransactionEntity(transactionEntity); + } else if(eventData.getType() == EventData.EventType.IN_TRANSIT && transactionEntity.getRole() == LENDER) { + lendingLibraryService.updateStatusByTransactionEntity(transactionEntity); + } else if(eventData.getType() == EventData.EventType.AWAITING_PICKUP) { + if(transactionEntity.getRole() == BORROWING_PICKUP) { + borrowingLibraryService.updateStatusByTransactionEntity(transactionEntity); + } else if (transactionEntity.getRole() == PICKUP) { + pickupLibraryService.updateStatusByTransactionEntity(transactionEntity); + } + } + }) ); } } } - } diff --git a/src/main/java/org/folio/dcb/listener/kafka/EventData.java b/src/main/java/org/folio/dcb/listener/kafka/EventData.java index 0ec23509..95917bba 100644 --- a/src/main/java/org/folio/dcb/listener/kafka/EventData.java +++ b/src/main/java/org/folio/dcb/listener/kafka/EventData.java @@ -9,6 +9,6 @@ public class EventData { private String requestId; public enum EventType { - CHECK_IN, CHECK_OUT, CANCEL + CHECK_IN, CHECK_OUT, IN_TRANSIT, AWAITING_PICKUP, CANCEL } } diff --git a/src/main/java/org/folio/dcb/repository/TransactionAuditRepository.java b/src/main/java/org/folio/dcb/repository/TransactionAuditRepository.java new file mode 100644 index 00000000..f03868be --- /dev/null +++ b/src/main/java/org/folio/dcb/repository/TransactionAuditRepository.java @@ -0,0 +1,18 @@ +package org.folio.dcb.repository; + +import org.folio.dcb.domain.entity.TransactionAuditEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface TransactionAuditRepository extends JpaRepository { + + @Query(value = "SELECT * FROM transactions_audit " + + "WHERE transaction_id = :trnId " + + "and created_date = (SELECT MAX(created_date) FROM transactions_audit WHERE transaction_id = :trnId);", nativeQuery = true) + Optional findLatestTransactionAuditEntityByDcbTransactionId(@Param("trnId") String trnId); +} diff --git a/src/main/java/org/folio/dcb/service/CirculationItemService.java b/src/main/java/org/folio/dcb/service/CirculationItemService.java index 4d7b0474..addf7431 100644 --- a/src/main/java/org/folio/dcb/service/CirculationItemService.java +++ b/src/main/java/org/folio/dcb/service/CirculationItemService.java @@ -1,12 +1,12 @@ package org.folio.dcb.service; -import org.folio.dcb.domain.dto.CirculationItemRequest; +import org.folio.dcb.domain.dto.CirculationItem; import org.folio.dcb.domain.dto.DcbItem; public interface CirculationItemService { void checkIfItemExistsAndCreate(DcbItem dcbTransaction, String pickupServicePointId); - CirculationItemRequest fetchItemById(String itemId); + CirculationItem fetchItemById(String itemId); } diff --git a/src/main/java/org/folio/dcb/service/TransactionAuditService.java b/src/main/java/org/folio/dcb/service/TransactionAuditService.java new file mode 100644 index 00000000..4e72ae1b --- /dev/null +++ b/src/main/java/org/folio/dcb/service/TransactionAuditService.java @@ -0,0 +1,10 @@ +package org.folio.dcb.service; + +import org.folio.dcb.domain.dto.DcbTransaction; + +public interface TransactionAuditService { + + void logErrorIfTransactionAuditExists(String dcbTransactionId, String errorMsg); + void logErrorIfTransactionAuditNotExists(String dcbTransactionId, DcbTransaction dcbTransaction, String errorMsg); + +} diff --git a/src/main/java/org/folio/dcb/service/impl/BaseLibraryService.java b/src/main/java/org/folio/dcb/service/impl/BaseLibraryService.java index 0f77e1e3..5272894a 100644 --- a/src/main/java/org/folio/dcb/service/impl/BaseLibraryService.java +++ b/src/main/java/org/folio/dcb/service/impl/BaseLibraryService.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.ObjectUtils; -import org.folio.dcb.domain.dto.CirculationItemRequest; +import org.folio.dcb.domain.dto.CirculationItem; import org.folio.dcb.domain.dto.CirculationRequest; import org.folio.dcb.domain.dto.DcbTransaction; import org.folio.dcb.domain.dto.TransactionStatus; @@ -88,13 +88,13 @@ public void checkUserTypeAndThrowIfMismatch(String userType) { public void updateStatusByTransactionEntity(TransactionEntity transactionEntity) { log.debug("updateTransactionStatus:: Received checkIn event for itemId: {}", transactionEntity.getItemId()); - CirculationItemRequest circulationItemRequest = circulationItemService.fetchItemById(transactionEntity.getItemId()); - var circulationItemRequestStatus = circulationItemRequest.getStatus().getName(); - if (OPEN == transactionEntity.getStatus() && AWAITING_PICKUP == circulationItemRequestStatus) { + CirculationItem circulationItem = circulationItemService.fetchItemById(transactionEntity.getItemId()); + var circulationItemStatus = circulationItem.getStatus().getName(); + if (OPEN == transactionEntity.getStatus() && AWAITING_PICKUP == circulationItemStatus) { log.info(UPDATE_STATUS_BY_TRANSACTION_ENTITY_LOG_MESSAGE_PATTERN, transactionEntity.getStatus().getValue(), TransactionStatus.StatusEnum.AWAITING_PICKUP.getValue()); updateTransactionEntity(transactionEntity, TransactionStatus.StatusEnum.AWAITING_PICKUP); - } else if (TransactionStatus.StatusEnum.AWAITING_PICKUP == transactionEntity.getStatus() && CHECKED_OUT == circulationItemRequestStatus) { + } else if (TransactionStatus.StatusEnum.AWAITING_PICKUP == transactionEntity.getStatus() && CHECKED_OUT == circulationItemStatus) { log.info(UPDATE_STATUS_BY_TRANSACTION_ENTITY_LOG_MESSAGE_PATTERN, transactionEntity.getStatus().getValue(), ITEM_CHECKED_OUT.getValue()); updateTransactionEntity(transactionEntity, ITEM_CHECKED_OUT); @@ -104,7 +104,7 @@ public void updateStatusByTransactionEntity(TransactionEntity transactionEntity) updateTransactionEntity(transactionEntity, TransactionStatus.StatusEnum.ITEM_CHECKED_IN); } else { log.info("updateStatusByTransactionEntity:: Item status is {}. So status of transaction is not updated", - circulationItemRequestStatus); + circulationItemStatus); } } diff --git a/src/main/java/org/folio/dcb/service/impl/CirculationItemServiceImpl.java b/src/main/java/org/folio/dcb/service/impl/CirculationItemServiceImpl.java index 805a1145..b593c670 100644 --- a/src/main/java/org/folio/dcb/service/impl/CirculationItemServiceImpl.java +++ b/src/main/java/org/folio/dcb/service/impl/CirculationItemServiceImpl.java @@ -1,17 +1,18 @@ package org.folio.dcb.service.impl; -import feign.FeignException; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.StringUtils; import org.folio.dcb.client.feign.CirculationItemClient; -import org.folio.dcb.domain.dto.CirculationItemRequest; +import org.folio.dcb.domain.dto.CirculationItem; import org.folio.dcb.domain.dto.DcbItem; import org.folio.dcb.domain.dto.ItemStatus; import org.folio.dcb.service.CirculationItemService; import org.folio.dcb.service.ItemService; import org.springframework.stereotype.Service; +import java.util.Objects; + import static org.folio.dcb.domain.dto.ItemStatus.NameEnum.IN_TRANSIT; import static org.folio.dcb.utils.DCBConstants.HOLDING_ID; import static org.folio.dcb.utils.DCBConstants.LOAN_TYPE_ID; @@ -30,18 +31,22 @@ public class CirculationItemServiceImpl implements CirculationItemService { public void checkIfItemExistsAndCreate(DcbItem dcbItem, String pickupServicePointId) { var dcbItemId = dcbItem.getId(); log.debug("checkIfItemExistsAndCreate:: generate Circulation item by DcbItem with id={} if nit doesn't exist.", dcbItemId); - - try { - log.debug("fetchOrCreateItem:: trying to find existed Circulation item"); - circulationItemClient.retrieveCirculationItemById(dcbItemId); - } catch (FeignException.NotFound ex) { + var circulationItem = fetchCirculationItemByIdAndBarcode(dcbItemId, dcbItem.getBarcode()); + if(Objects.isNull(circulationItem)) { log.warn("Circulation item not found by id={}. Creating it.", dcbItemId); createCirculationItem(dcbItem, pickupServicePointId); } + } + private CirculationItem fetchCirculationItemByIdAndBarcode(String id, String barcode) { + return circulationItemClient.fetchItemByIdAndBarcode("id==" + id + " and barcode==" + barcode) + .getItems() + .stream() + .findFirst() + .orElse(null); } - public CirculationItemRequest fetchItemById(String itemId) { + public CirculationItem fetchItemById(String itemId) { log.info("fetchItemById:: fetching item details for id {} ", itemId); return circulationItemClient.retrieveCirculationItemById(itemId); } @@ -51,8 +56,8 @@ private void createCirculationItem(DcbItem item, String pickupServicePointId){ String materialType = StringUtils.isBlank(item.getMaterialType()) ? MATERIAL_TYPE_NAME_BOOK : item.getMaterialType(); var materialTypeId = itemService.fetchItemMaterialTypeIdByMaterialTypeName(materialType); - CirculationItemRequest circulationItemRequest = - CirculationItemRequest.builder() + CirculationItem circulationItem = + CirculationItem.builder() .id(item.getId()) .barcode(item.getBarcode()) .status(ItemStatus.builder() @@ -66,6 +71,6 @@ private void createCirculationItem(DcbItem item, String pickupServicePointId){ .lendingLibraryCode(item.getLendingLibraryCode()) .build(); - circulationItemClient.createCirculationItem(item.getId(), circulationItemRequest); + circulationItemClient.createCirculationItem(item.getId(), circulationItem); } } diff --git a/src/main/java/org/folio/dcb/service/impl/TransactionAuditServiceImpl.java b/src/main/java/org/folio/dcb/service/impl/TransactionAuditServiceImpl.java new file mode 100644 index 00000000..5b6a64b1 --- /dev/null +++ b/src/main/java/org/folio/dcb/service/impl/TransactionAuditServiceImpl.java @@ -0,0 +1,84 @@ +package org.folio.dcb.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.dcb.domain.dto.DcbTransaction; +import org.folio.dcb.domain.entity.TransactionAuditEntity; +import org.folio.dcb.domain.entity.TransactionEntity; +import org.folio.dcb.domain.mapper.TransactionMapper; +import org.folio.dcb.repository.TransactionAuditRepository; +import org.folio.dcb.service.TransactionAuditService; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class TransactionAuditServiceImpl implements TransactionAuditService { + private static final String ERROR_ACTION = "ERROR"; + private static final String DUPLICATE_ERROR_ACTION = "DUPLICATE_ERROR"; + private static final String DUPLICATE_ERROR_TRANSACTION_ID = "-1"; + + private final TransactionMapper transactionMapper; + private final TransactionAuditRepository transactionAuditRepository; + @Override + public void logErrorIfTransactionAuditExists(String dcbTransactionId, String errorMsg) { + log.debug("logTheErrorForExistedTransactionAudit:: dcbTransactionId = {}, err = {}", dcbTransactionId, errorMsg); + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(dcbTransactionId).orElse(null); + + if (auditExisting != null) { + TransactionAuditEntity auditError = generateTrnAuditEntityFromTheFoundOneWithError(auditExisting, errorMsg); + transactionAuditRepository.save(auditError); + } + } + + /** + * For the case the error happens during DCB transaction creation. + * At this time there is no transaction_audit data persisted, which refers to current DCB transaction. + * So the transaction_audit log is created with empty "before" and "after" states and the DCB transaction content is merged with the error message. + * The exceptional case is the attempt of the DCB transaction duplication by the id (it means the TransactionEntity with such an id already exists). + * It triggers DUPLICATE_ERROR, which is logged with the particular transaction_audit (DUPLICATE_ERROR_ACTION) + * and refers to not existed DCB transaction (transaction_audit.transaction_id = -1) + * */ + @Override + public void logErrorIfTransactionAuditNotExists(String dcbTransactionId, DcbTransaction dcbTransaction, String errorMsg) { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(dcbTransactionId).orElse(null); + TransactionEntity transactionMapped = transactionMapper.mapToEntity(dcbTransactionId, dcbTransaction); + TransactionAuditEntity auditError = generateTrnAuditEntityByTrnEntityWithError(dcbTransactionId, transactionMapped, errorMsg); + + if (auditExisting != null) { + log.debug("logTheErrorForNotExistedTransactionAudit:: dcbTransactionId = {}, dcbTransaction = {}, err = {}", dcbTransactionId, dcbTransaction, errorMsg); + auditError.setTransactionId(DUPLICATE_ERROR_TRANSACTION_ID); + auditError.setAction(DUPLICATE_ERROR_ACTION); + } + + transactionAuditRepository.save(auditError); + } + + private TransactionAuditEntity generateTrnAuditEntityFromTheFoundOneWithError(TransactionAuditEntity existed, String errorMsg) { + TransactionAuditEntity auditError = new TransactionAuditEntity(); + auditError.setId(UUID.randomUUID()); + auditError.setTransactionId(existed.getTransactionId()); + auditError.setAction(ERROR_ACTION); + auditError.setBefore(existed.getAfter()); + auditError.setAfter(existed.getAfter()); + auditError.setErrorMessage(errorMsg); + + return auditError; + } + + private TransactionAuditEntity generateTrnAuditEntityByTrnEntityWithError(String dcbTransactionId, TransactionEntity trnE, String errorMsg) { + String errorMessage = String.format("dcbTransactionId = %s; dcb transaction content = %s; error message = %s.", dcbTransactionId, trnE.toString(), errorMsg); + + TransactionAuditEntity auditError = new TransactionAuditEntity(); + auditError.setId(UUID.randomUUID()); + auditError.setTransactionId(dcbTransactionId); + auditError.setAction(ERROR_ACTION); + auditError.setBefore(null); + auditError.setAfter(null); + auditError.setErrorMessage(errorMessage); + + return auditError; + } +} diff --git a/src/main/java/org/folio/dcb/utils/KafkaEvent.java b/src/main/java/org/folio/dcb/utils/KafkaEvent.java new file mode 100644 index 00000000..f096fcef --- /dev/null +++ b/src/main/java/org/folio/dcb/utils/KafkaEvent.java @@ -0,0 +1,53 @@ +package org.folio.dcb.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Getter +public class KafkaEvent { + + private EventType eventType; + private JsonNode newNode; + private JsonNode oldNode; + private static final ObjectMapper objectMapper = new ObjectMapper(); + public static final String ACTION = "action"; + public static final String STATUS = "status"; + + public KafkaEvent(String eventPayload) { + try { + JsonNode jsonNode = objectMapper.readTree(eventPayload); + setEventType(jsonNode.get("type").asText()); + setNewNode(jsonNode.get("data")); + setOldNode(jsonNode.get("data")); + } catch (Exception e) { + log.error("Could not parse input payload for processing event", e); + } + } + + private void setEventType(String eventType) { + this.eventType = EventType.valueOf(eventType); + } + + private void setNewNode(JsonNode dataNode) { + if(dataNode != null) { + this.newNode = dataNode.get("new"); + } + } + + private void setOldNode(JsonNode dataNode) { + if(dataNode != null) { + this.oldNode = dataNode.get("old"); + } + } + + public boolean hasNewNode() { + return this.newNode != null; + } + + enum EventType { + UPDATED, CREATED + } +} diff --git a/src/main/java/org/folio/dcb/utils/TransactionHelper.java b/src/main/java/org/folio/dcb/utils/TransactionHelper.java index 15f63f98..5a61cd2d 100644 --- a/src/main/java/org/folio/dcb/utils/TransactionHelper.java +++ b/src/main/java/org/folio/dcb/utils/TransactionHelper.java @@ -1,7 +1,5 @@ package org.folio.dcb.utils; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.log4j.Log4j2; import org.folio.dcb.listener.kafka.EventData; import org.springframework.messaging.MessageHeaders; @@ -10,10 +8,13 @@ import java.util.Collections; import java.util.List; +import static org.folio.dcb.utils.KafkaEvent.ACTION; +import static org.folio.dcb.utils.KafkaEvent.STATUS; + @Log4j2 public class TransactionHelper { - private static final ObjectMapper objectMapper = new ObjectMapper(); - + private static final String LOAN_ACTION_CHECKED_OUT = "checkedout"; + private static final String LOAN_ACTION_CHECKED_IN = "checkedin"; private TransactionHelper(){} public static List getHeaderValue(MessageHeaders headers, String headerName, String defaultValue) { @@ -24,34 +25,38 @@ public static List getHeaderValue(MessageHeaders headers, String headerN return value == null ? Collections.emptyList() : Collections.singletonList(value); } - public static EventData parseEvent(String eventPayload) { - try { - JsonNode jsonNode = objectMapper.readTree(eventPayload); - JsonNode dataNode = jsonNode.get("data"); - String typeNode = jsonNode.get("type").asText(); - JsonNode newDataNode = (dataNode != null) ? dataNode.get("new") : null; - - if (newDataNode != null && newDataNode.has("itemId") && typeNode.equals("CREATED")) { + public static EventData parseLoanEvent(String eventPayload) { + KafkaEvent kafkaEvent = new KafkaEvent(eventPayload); + if (kafkaEvent.hasNewNode() && kafkaEvent.getNewNode().has("itemId")) { EventData eventData = new EventData(); - eventData.setItemId(newDataNode.get("itemId").asText()); - - if (newDataNode.has("action")) { - eventData.setType(EventData.EventType.CHECK_OUT); - } else { - eventData.setType(EventData.EventType.CHECK_IN); + eventData.setItemId(kafkaEvent.getNewNode().get("itemId").asText()); + if (kafkaEvent.getNewNode().has(ACTION)) { + if(LOAN_ACTION_CHECKED_OUT.equals(kafkaEvent.getNewNode().get(ACTION).asText())){ + eventData.setType(EventData.EventType.CHECK_OUT); + } else if(LOAN_ACTION_CHECKED_IN.equals(kafkaEvent.getNewNode().get(ACTION).asText())) { + eventData.setType(EventData.EventType.CHECK_IN); + } } - return eventData; - } else if(typeNode.equals("UPDATED") && newDataNode != null && newDataNode.has("status") - && RequestStatus.CLOSED_CANCELLED == RequestStatus.from(newDataNode.get("status").asText())){ + } + return null; + } + + public static EventData parseRequestEvent(String eventPayload){ + KafkaEvent kafkaEvent = new KafkaEvent(eventPayload); + if(kafkaEvent.getEventType() == KafkaEvent.EventType.UPDATED && kafkaEvent.hasNewNode() + && kafkaEvent.getNewNode().has(STATUS)){ EventData eventData = new EventData(); - eventData.setRequestId(newDataNode.get("id").asText()); - eventData.setType(EventData.EventType.CANCEL); + eventData.setRequestId(kafkaEvent.getNewNode().get("id").asText()); + RequestStatus requestStatus = RequestStatus.from(kafkaEvent.getNewNode().get(STATUS).asText()); + switch (requestStatus) { + case OPEN_IN_TRANSIT -> eventData.setType(EventData.EventType.IN_TRANSIT); + case OPEN_AWAITING_PICKUP -> eventData.setType(EventData.EventType.AWAITING_PICKUP); + case CLOSED_CANCELLED -> eventData.setType(EventData.EventType.CANCEL); + default -> log.info("parseRequestEvent:: Request status {} is not supported", requestStatus); + } return eventData; } - } catch (Exception e) { - log.error("Could not parse input payload for processing event", e); - } return null; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bb4a0952..5e28cd37 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -67,7 +67,7 @@ management: endpoints: web: exposure: - include: info,health,env,httptrace + include: ${ACTUATOR_EXPOSURE:health,info,loggers} base-path: /admin # endpoint: # health: diff --git a/src/main/resources/db/changelog/changes/create-audit-table.xml b/src/main/resources/db/changelog/changes/create-audit-table.xml index eedceed9..fe94e38e 100644 --- a/src/main/resources/db/changelog/changes/create-audit-table.xml +++ b/src/main/resources/db/changelog/changes/create-audit-table.xml @@ -23,4 +23,11 @@ + + to persist the error action and error message + + + + + diff --git a/src/main/resources/swagger.api/dcb_transaction.yaml b/src/main/resources/swagger.api/dcb_transaction.yaml index 8ab8edfe..3a6c44a5 100644 --- a/src/main/resources/swagger.api/dcb_transaction.yaml +++ b/src/main/resources/swagger.api/dcb_transaction.yaml @@ -144,7 +144,7 @@ components: $ref: 'schemas/checkOutRequest.yaml#/CheckOutRequest' ServicePointRequest: $ref: 'schemas/ServicePointRequest.yaml#/ServicePointRequest' - CirculationItemRequest: - $ref: 'schemas/CirculationItemRequest.yaml#/CirculationItemRequest' + CirculationItemCollection: + $ref: 'schemas/CirculationItem.yaml#/CirculationItemCollection' MaterialTypeCollection: $ref: 'schemas/MaterialType.yaml#/MaterialTypeCollection' diff --git a/src/main/resources/swagger.api/schemas/CirculationItemRequest.yaml b/src/main/resources/swagger.api/schemas/CirculationItem.yaml similarity index 63% rename from src/main/resources/swagger.api/schemas/CirculationItemRequest.yaml rename to src/main/resources/swagger.api/schemas/CirculationItem.yaml index c84b0cd2..820cef74 100644 --- a/src/main/resources/swagger.api/schemas/CirculationItemRequest.yaml +++ b/src/main/resources/swagger.api/schemas/CirculationItem.yaml @@ -1,6 +1,6 @@ -CirculationItemRequest: +CirculationItem: type: "object" - description: "CirculationItemRequest" + description: "CirculationItem" properties: id: description: the system assigned unique ID of the circulation item record @@ -24,3 +24,18 @@ CirculationItemRequest: description: 5 digit agency code which identifies the lending library type: string additionalProperties: false + +CirculationItemCollection: + type: object + properties: + items: + type: array + description: "Circulation Item collection" + items: + $ref: "CirculationItem.yaml#/CirculationItem" + totalRecords: + type: integer + additionalProperties: false + required: + - circulationItems + - totalRecords diff --git a/src/test/java/org/folio/dcb/controller/TransactionApiControllerTest.java b/src/test/java/org/folio/dcb/controller/TransactionApiControllerTest.java index 8b2ce044..e73b9298 100644 --- a/src/test/java/org/folio/dcb/controller/TransactionApiControllerTest.java +++ b/src/test/java/org/folio/dcb/controller/TransactionApiControllerTest.java @@ -2,8 +2,11 @@ import org.folio.dcb.domain.dto.DcbItem; import org.folio.dcb.domain.dto.TransactionStatus; +import org.folio.dcb.domain.entity.TransactionAuditEntity; +import org.folio.dcb.repository.TransactionAuditRepository; import org.folio.dcb.repository.TransactionRepository; import org.folio.spring.service.SystemUserScopedExecutionService; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -35,8 +38,14 @@ class TransactionApiControllerTest extends BaseIT { + private static final String TRANSACTION_AUDIT_ERROR_ACTION = "ERROR"; + private static final String TRANSACTION_AUDIT_DUPLICATE_ERROR_ACTION = "DUPLICATE_ERROR"; + private static final String DUPLICATE_ERROR_TRANSACTION_ID = "-1"; + @Autowired private TransactionRepository transactionRepository; + @Autowired + private TransactionAuditRepository transactionAuditRepository; @Autowired private SystemUserScopedExecutionService systemUserScopedExecutionService; @@ -65,6 +74,17 @@ void createLendingCirculationRequestTest() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpectAll(status().is4xxClientError(), jsonPath("$.errors[0].code", is("DUPLICATE_ERROR"))); + + // check for DUPLICATE_ERROR propagated into transactions_audit. + systemUserScopedExecutionService.executeAsyncSystemUserScoped( + TENANT, + () -> { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(DCB_TRANSACTION_ID) + .orElse(null); + Assertions.assertNotNull(auditExisting); + Assertions.assertNotEquals(TRANSACTION_AUDIT_DUPLICATE_ERROR_ACTION, auditExisting.getAction()); + Assertions.assertNotEquals(DUPLICATE_ERROR_TRANSACTION_ID, auditExisting.getTransactionId()); } + ); } @Test @@ -94,6 +114,17 @@ void createBorrowingPickupCirculationRequestTest() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpectAll(status().is4xxClientError(), jsonPath("$.errors[0].code", is("DUPLICATE_ERROR"))); + + // check for DUPLICATE_ERROR propagated into transactions_audit. + systemUserScopedExecutionService.executeAsyncSystemUserScoped( + TENANT, + () -> { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(DCB_TRANSACTION_ID) + .orElse(null); + Assertions.assertNotNull(auditExisting); + Assertions.assertNotEquals(TRANSACTION_AUDIT_DUPLICATE_ERROR_ACTION, auditExisting.getAction()); + Assertions.assertNotEquals(DUPLICATE_ERROR_TRANSACTION_ID, auditExisting.getTransactionId()); } + ); } @Test @@ -101,14 +132,26 @@ void createLendingCirculationRequestWithInvalidItemId() throws Exception { var dcbTransaction = createDcbTransactionByRole(LENDER); dcbTransaction.getItem().setId("5b95877d-86c0-4cb7-a0cd-7660b348ae5b"); + String trnId = UUID.randomUUID().toString(); + this.mockMvc.perform( - post("/transactions/" + UUID.randomUUID()) + post("/transactions/" + trnId) .content(asJsonString(dcbTransaction)) .headers(defaultHeaders()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpectAll(status().is4xxClientError(), jsonPath("$.errors[0].code", is("NOT_FOUND_ERROR"))); + + // check for transactions_audit error content. + systemUserScopedExecutionService.executeAsyncSystemUserScoped( + TENANT, + () -> { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(trnId) + .orElse(null); + Assertions.assertNotNull(auditExisting); + Assertions.assertEquals(TRANSACTION_AUDIT_ERROR_ACTION, auditExisting.getAction()); } + ); } @Test @@ -116,14 +159,25 @@ void createBorrowingPickupCirculationRequestWithInvalidDefaultNotExistedPatronId var dcbTransaction = createDcbTransactionByRole(BORROWING_PICKUP); dcbTransaction.getPatron().setId(NOT_EXISTED_PATRON_ID); + String trnId = UUID.randomUUID().toString(); this.mockMvc.perform( - post("/transactions/" + UUID.randomUUID()) + post("/transactions/" + trnId) .content(asJsonString(dcbTransaction)) .headers(defaultHeaders()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpectAll(status().is4xxClientError(), jsonPath("$.errors[0].code", is("NOT_FOUND_ERROR"))); + + // check for transactions_audit error content. + systemUserScopedExecutionService.executeAsyncSystemUserScoped( + TENANT, + () -> { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(trnId) + .orElse(null); + Assertions.assertNotNull(auditExisting); + Assertions.assertEquals(TRANSACTION_AUDIT_ERROR_ACTION, auditExisting.getAction()); } + ); } /** @@ -300,6 +354,16 @@ void createTransactionForPickupLibrary() throws Exception { .andExpectAll(status().is4xxClientError(), jsonPath("$.errors[0].code", is("DUPLICATE_ERROR"))); + // check for DUPLICATE_ERROR propagated into transactions_audit. + systemUserScopedExecutionService.executeAsyncSystemUserScoped( + TENANT, + () -> { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(DCB_TRANSACTION_ID) + .orElse(null); + Assertions.assertNotNull(auditExisting); + Assertions.assertNotEquals(TRANSACTION_AUDIT_DUPLICATE_ERROR_ACTION, auditExisting.getAction()); + Assertions.assertNotEquals(DUPLICATE_ERROR_TRANSACTION_ID, auditExisting.getTransactionId()); } + ); } @Test @@ -346,6 +410,47 @@ void createBorrowerCirculationRequestTest() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpectAll(status().is4xxClientError(), jsonPath("$.errors[0].code", is("DUPLICATE_ERROR"))); + + // check for DUPLICATE_ERROR propagated into transactions_audit. + systemUserScopedExecutionService.executeAsyncSystemUserScoped( + TENANT, + () -> { + TransactionAuditEntity auditExisting = transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(DCB_TRANSACTION_ID) + .orElse(null); + Assertions.assertNotNull(auditExisting); + Assertions.assertNotEquals(TRANSACTION_AUDIT_DUPLICATE_ERROR_ACTION, auditExisting.getAction()); + Assertions.assertNotEquals(DUPLICATE_ERROR_TRANSACTION_ID, auditExisting.getTransactionId()); } + ); + } + + @Test + void createBorrowerCirculationRequestWithoutExistingItemTest() throws Exception { + removeExistedTransactionFromDbIfSoExists(); + var dcbTransaction = createDcbTransactionByRole(BORROWER); + dcbTransaction.getItem().setBarcode("newItem"); + var dcbItem = createDcbItem(); + dcbItem.setBarcode("newItem"); + + this.mockMvc.perform( + post("/transactions/" + DCB_TRANSACTION_ID) + .content(asJsonString(dcbTransaction)) + .headers(defaultHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("CREATED")) + .andExpect(jsonPath("$.item").value(dcbItem)) + .andExpect(jsonPath("$.patron").value(createDcbPatronWithExactPatronId(EXISTED_PATRON_ID))); + + //Trying to create another transaction with same transaction id + this.mockMvc.perform( + post("/transactions/" + DCB_TRANSACTION_ID) + .content(asJsonString(createDcbTransactionByRole(BORROWER))) + .headers(defaultHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpectAll(status().is4xxClientError(), + jsonPath("$.errors[0].code", is("DUPLICATE_ERROR"))); } @Test @@ -582,6 +687,44 @@ void transactionCreationErrorIfInventoryItemExists() throws Exception { } + @Test + void transactionStatusUpdateFromCheckedInToCancelledAsBorrowerPickupTest() throws Exception { + var transactionID = UUID.randomUUID().toString(); + var dcbTransaction = createTransactionEntity(); + dcbTransaction.setStatus(TransactionStatus.StatusEnum.ITEM_CHECKED_IN); + dcbTransaction.setRole(BORROWING_PICKUP); + dcbTransaction.setId(transactionID); + + systemUserScopedExecutionService.executeAsyncSystemUserScoped(TENANT, () -> transactionRepository.save(dcbTransaction)); + + mockMvc.perform( + put("/transactions/" + transactionID + "/status") + .content(asJsonString(createTransactionStatus(TransactionStatus.StatusEnum.CANCELLED))) + .headers(defaultHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void transactionStatusUpdateFromCheckedInToCancelledAsPickupTest() throws Exception { + var transactionID = UUID.randomUUID().toString(); + var dcbTransaction = createTransactionEntity(); + dcbTransaction.setStatus(TransactionStatus.StatusEnum.ITEM_CHECKED_IN); + dcbTransaction.setRole(PICKUP); + dcbTransaction.setId(transactionID); + + systemUserScopedExecutionService.executeAsyncSystemUserScoped(TENANT, () -> transactionRepository.save(dcbTransaction)); + + mockMvc.perform( + put("/transactions/" + transactionID + "/status") + .content(asJsonString(createTransactionStatus(TransactionStatus.StatusEnum.CANCELLED))) + .headers(defaultHeaders()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + private void removeExistedTransactionFromDbIfSoExists() { systemUserScopedExecutionService.executeAsyncSystemUserScoped(TENANT, () -> { if (transactionRepository.existsById(DCB_TRANSACTION_ID)){ diff --git a/src/test/java/org/folio/dcb/listener/CirculationCheckOutEventListenerTest.java b/src/test/java/org/folio/dcb/listener/CirculationCheckOutEventListenerTest.java deleted file mode 100644 index 35751cb2..00000000 --- a/src/test/java/org/folio/dcb/listener/CirculationCheckOutEventListenerTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.folio.dcb.listener; - -import org.folio.dcb.controller.BaseIT; -import org.folio.dcb.domain.dto.TransactionStatus; -import org.folio.dcb.listener.kafka.CirculationEventListener; -import org.folio.dcb.repository.TransactionRepository; -import org.folio.dcb.service.impl.BorrowingPickupLibraryServiceImpl; -import org.folio.spring.client.AuthnClient; -import org.folio.spring.integration.XOkapiHeaders; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.messaging.MessageHeaders; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.BORROWING_PICKUP; -import static org.folio.dcb.utils.EntityUtils.createTransactionEntity; -import static org.folio.dcb.utils.EntityUtils.getMockDataAsString; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -@SpringBootTest -class CirculationCheckOutEventListenerTest extends BaseIT { - - private static final String CHECK_OUT_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/check_out.json"); - - @Mock - private BorrowingPickupLibraryServiceImpl libraryService; - @Autowired - private CirculationEventListener eventListener ; - @Mock - private AuthnClient authnClient; - @MockBean - private TransactionRepository transactionRepository; - - @Test - void handleCheckingOutTest() { - var transactionEntity = createTransactionEntity(); - transactionEntity.setRole(BORROWING_PICKUP); - transactionEntity.setStatus(TransactionStatus.StatusEnum.AWAITING_PICKUP); - transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); - MessageHeaders messageHeaders = getMessageHeaders(); - when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckOutEvent(CHECK_OUT_EVENT_SAMPLE, messageHeaders); - Mockito.verify(transactionRepository).save(any()); - } - - @Test - void handleCheckInEventInBorrowingFromOpenToAwaitingPickup_1() { - var transactionEntity = createTransactionEntity(); - transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); - transactionEntity.setStatus(TransactionStatus.StatusEnum.AWAITING_PICKUP); - transactionEntity.setRole(BORROWING_PICKUP); - MessageHeaders messageHeaders = getMessageHeaders(); - when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckOutEvent(CHECK_OUT_EVENT_SAMPLE, messageHeaders); - Mockito.verify(transactionRepository).save(any()); - } - - @Test - void handleCheckingInWithIncorrectDataTest() { - var transactionEntity = createTransactionEntity(); - transactionEntity.setRole(BORROWING_PICKUP); - MessageHeaders messageHeaders = getMessageHeaders(); - assertDoesNotThrow(() -> eventListener.handleCheckOutEvent(null, messageHeaders)); - } - - private MessageHeaders getMessageHeaders() { - Map header = new HashMap<>(); - header.put(XOkapiHeaders.TENANT, TENANT.getBytes()); - return new MessageHeaders(header); - } -} diff --git a/src/test/java/org/folio/dcb/listener/CirculationCheckInEventListenerTest.java b/src/test/java/org/folio/dcb/listener/CirculationLoanEventListenerTest.java similarity index 62% rename from src/test/java/org/folio/dcb/listener/CirculationCheckInEventListenerTest.java rename to src/test/java/org/folio/dcb/listener/CirculationLoanEventListenerTest.java index f373b3f5..c6e17d6f 100644 --- a/src/test/java/org/folio/dcb/listener/CirculationCheckInEventListenerTest.java +++ b/src/test/java/org/folio/dcb/listener/CirculationLoanEventListenerTest.java @@ -4,7 +4,7 @@ import org.folio.dcb.domain.dto.TransactionStatus; import org.folio.dcb.listener.kafka.CirculationEventListener; import org.folio.dcb.repository.TransactionRepository; -import org.folio.dcb.service.impl.LendingLibraryServiceImpl; +import org.folio.dcb.service.impl.BorrowingPickupLibraryServiceImpl; import org.folio.spring.client.AuthnClient; import org.folio.spring.integration.XOkapiHeaders; import org.junit.jupiter.api.Test; @@ -26,18 +26,16 @@ import static org.folio.dcb.utils.EntityUtils.getMockDataAsString; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @SpringBootTest -class CirculationCheckInEventListenerTest extends BaseIT { +class CirculationLoanEventListenerTest extends BaseIT { - private static final String CHECK_IN_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/check_in.json"); - - @MockBean - private LendingLibraryServiceImpl libraryService; + private static final String CHECK_OUT_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/check_out.json"); + private static final String CHECK_IN_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/loan_check_in.json"); + @Mock + private BorrowingPickupLibraryServiceImpl libraryService; @Autowired private CirculationEventListener eventListener ; @Mock @@ -46,73 +44,76 @@ class CirculationCheckInEventListenerTest extends BaseIT { private TransactionRepository transactionRepository; @Test - void handleCheckingInTest() { + void handleCheckingOutTest() { var transactionEntity = createTransactionEntity(); - transactionEntity.setRole(LENDER); + transactionEntity.setRole(BORROWING_PICKUP); + transactionEntity.setStatus(TransactionStatus.StatusEnum.AWAITING_PICKUP); + transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); MessageHeaders messageHeaders = getMessageHeaders(); when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckInEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); - Mockito.verify(libraryService, times(1)).updateStatusByTransactionEntity(any()); + eventListener.handleLoanEvent(CHECK_OUT_EVENT_SAMPLE, messageHeaders); + Mockito.verify(transactionRepository).save(any()); } @Test void handleCheckInEventInBorrowingFromOpenToAwaitingPickup_1() { var transactionEntity = createTransactionEntity(); - transactionEntity.setItemId("5b95877d-86c0-4cb7-a0cd-7660b348ae5b"); - transactionEntity.setStatus(TransactionStatus.StatusEnum.OPEN); + transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); + transactionEntity.setStatus(TransactionStatus.StatusEnum.AWAITING_PICKUP); transactionEntity.setRole(BORROWING_PICKUP); MessageHeaders messageHeaders = getMessageHeaders(); when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckInEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); + eventListener.handleLoanEvent(CHECK_OUT_EVENT_SAMPLE, messageHeaders); Mockito.verify(transactionRepository).save(any()); } @Test - void handleCheckInEventInBorrowingFromOpenToAwaitingPickup_2() { + void handleCheckInEventInPickupFromItemCheckedOutToCheckedIn() { var transactionEntity = createTransactionEntity(); - transactionEntity.setItemId("5b95877d-86c0-4cb7-a0cd-7660b348ae5c"); - transactionEntity.setStatus(TransactionStatus.StatusEnum.OPEN); - transactionEntity.setRole(BORROWING_PICKUP); + transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); + transactionEntity.setStatus(TransactionStatus.StatusEnum.ITEM_CHECKED_OUT); + transactionEntity.setRole(PICKUP); MessageHeaders messageHeaders = getMessageHeaders(); when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckInEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); - Mockito.verify(transactionRepository, never()).save(any()); + eventListener.handleLoanEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); + Mockito.verify(transactionRepository).save(any()); } @Test - void handleCheckingInWithIncorrectDataTest() { + void handleCheckInEventInBorrowingPickupFromItemCheckedOutToCheckedIn() { var transactionEntity = createTransactionEntity(); - transactionEntity.setRole(LENDER); + transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); + transactionEntity.setStatus(TransactionStatus.StatusEnum.ITEM_CHECKED_OUT); + transactionEntity.setRole(BORROWING_PICKUP); MessageHeaders messageHeaders = getMessageHeaders(); - assertDoesNotThrow(() -> eventListener.handleCheckInEvent(null, messageHeaders)); + when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); + eventListener.handleLoanEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); + Mockito.verify(transactionRepository).save(any()); } - private MessageHeaders getMessageHeaders() { - Map header = new HashMap<>(); - header.put(XOkapiHeaders.TENANT, TENANT.getBytes()); - return new MessageHeaders(header); - } @Test - void handleCheckInEventInPickupFromOpenToAwaitingPickupTest() { + void handleCheckInEventInLenderFromItemCheckedInToClosedIn() { var transactionEntity = createTransactionEntity(); - transactionEntity.setItemId("5b95877d-86c0-4cb7-a0cd-7660b348ae5d"); - transactionEntity.setStatus(TransactionStatus.StatusEnum.OPEN); - transactionEntity.setRole(PICKUP); + transactionEntity.setItemId("8db107f5-12aa-479f-9c07-39e7c9cf2e4d"); + transactionEntity.setStatus(TransactionStatus.StatusEnum.ITEM_CHECKED_IN); + transactionEntity.setRole(LENDER); MessageHeaders messageHeaders = getMessageHeaders(); when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckInEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); + eventListener.handleLoanEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); Mockito.verify(transactionRepository).save(any()); } @Test - void handleCheckInEventInPickupWithIncorrectDataTest() { + void handleCheckingInWithIncorrectDataTest() { var transactionEntity = createTransactionEntity(); - transactionEntity.setItemId("5b95877d-86c0-4cb7-a0cd-7660b348ae5d"); - transactionEntity.setStatus(TransactionStatus.StatusEnum.CREATED); - transactionEntity.setRole(PICKUP); + transactionEntity.setRole(BORROWING_PICKUP); MessageHeaders messageHeaders = getMessageHeaders(); - when(transactionRepository.findTransactionByItemIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleCheckInEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); - Mockito.verify(transactionRepository, never()).save(any()); + assertDoesNotThrow(() -> eventListener.handleLoanEvent(null, messageHeaders)); + } + + private MessageHeaders getMessageHeaders() { + Map header = new HashMap<>(); + header.put(XOkapiHeaders.TENANT, TENANT.getBytes()); + return new MessageHeaders(header); } } diff --git a/src/test/java/org/folio/dcb/listener/CirculationRequestCancelEventListenerTest.java b/src/test/java/org/folio/dcb/listener/CirculationRequestCancelEventListenerTest.java deleted file mode 100644 index ef0f246f..00000000 --- a/src/test/java/org/folio/dcb/listener/CirculationRequestCancelEventListenerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.folio.dcb.listener; - -import org.folio.dcb.controller.BaseIT; -import org.folio.dcb.listener.kafka.CirculationEventListener; -import org.folio.dcb.repository.TransactionRepository; -import org.folio.dcb.service.impl.BaseLibraryService; -import org.folio.spring.integration.XOkapiHeaders; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.messaging.MessageHeaders; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.*; -import static org.folio.dcb.utils.EntityUtils.createTransactionEntity; -import static org.folio.dcb.utils.EntityUtils.getMockDataAsString; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@SpringBootTest -class CirculationRequestCancelEventListenerTest extends BaseIT { - - private static final String REQUEST_CANCEL_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/cancel_request.json"); - - @MockBean - private BaseLibraryService baseLibraryService; - - @Autowired - private CirculationEventListener eventListener ; - - @MockBean - private TransactionRepository transactionRepository; - - @Test - void handleCancelRequestTest() { - var transactionEntity = createTransactionEntity(); - transactionEntity.setRole(LENDER); - MessageHeaders messageHeaders = getMessageHeaders(); - when(transactionRepository.findTransactionByRequestIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); - eventListener.handleRequestCancelEvent(REQUEST_CANCEL_EVENT_SAMPLE, messageHeaders); - Mockito.verify(baseLibraryService, times(1)).cancelTransactionEntity(any()); - } - - @Test - void handleRequestCancelWithIncorrectDataTest() { - var transactionEntity = createTransactionEntity(); - transactionEntity.setRole(LENDER); - MessageHeaders messageHeaders = getMessageHeaders(); - assertDoesNotThrow(() -> eventListener.handleRequestCancelEvent(null, messageHeaders)); - } - - private MessageHeaders getMessageHeaders() { - Map header = new HashMap<>(); - header.put(XOkapiHeaders.TENANT, TENANT.getBytes()); - return new MessageHeaders(header); - } -} diff --git a/src/test/java/org/folio/dcb/listener/CirculationRequestEventListenerTest.java b/src/test/java/org/folio/dcb/listener/CirculationRequestEventListenerTest.java new file mode 100644 index 00000000..131a697d --- /dev/null +++ b/src/test/java/org/folio/dcb/listener/CirculationRequestEventListenerTest.java @@ -0,0 +1,106 @@ +package org.folio.dcb.listener; + +import org.folio.dcb.controller.BaseIT; +import org.folio.dcb.domain.dto.TransactionStatus; +import org.folio.dcb.listener.kafka.CirculationEventListener; +import org.folio.dcb.repository.TransactionRepository; +import org.folio.dcb.service.CirculationItemService; +import org.folio.dcb.service.impl.BaseLibraryService; +import org.folio.spring.integration.XOkapiHeaders; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.messaging.MessageHeaders; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.*; +import static org.folio.dcb.utils.EntityUtils.createCirculationItem; +import static org.folio.dcb.utils.EntityUtils.createTransactionEntity; +import static org.folio.dcb.utils.EntityUtils.getMockDataAsString; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest +class CirculationRequestEventListenerTest extends BaseIT { + + private static final String CHECK_IN_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/check_in.json"); + + private static final String REQUEST_CANCEL_EVENT_SAMPLE = getMockDataAsString("mockdata/kafka/cancel_request.json"); + + @InjectMocks + private BaseLibraryService baseLibraryService; + + @Autowired + private CirculationEventListener eventListener ; + + @MockBean + private TransactionRepository transactionRepository; + + @MockBean + private CirculationItemService circulationItemService; + + @Test + void handleCheckInEventInPickupFromOpenToAwaitingPickupTest() { + var transactionEntity = createTransactionEntity(); + transactionEntity.setItemId("5b95877d-86c0-4cb7-a0cd-7660b348ae5d"); + transactionEntity.setStatus(TransactionStatus.StatusEnum.OPEN); + transactionEntity.setRole(PICKUP); + + var circulationItem = createCirculationItem(); + circulationItem.setStatus(org.folio.dcb.domain.dto.ItemStatus.builder().name(org.folio.dcb.domain.dto.ItemStatus.NameEnum.AWAITING_PICKUP).build()); + + MessageHeaders messageHeaders = getMessageHeaders(); + when(transactionRepository.findTransactionByRequestIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); + when(circulationItemService.fetchItemById(anyString())).thenReturn(circulationItem); + eventListener.handleRequestEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); + Mockito.verify(transactionRepository).save(any()); + } + + @Test + void handleCheckInEventInBorrowingFromOpenToAwaitingPickup() { + var transactionEntity = createTransactionEntity(); + transactionEntity.setItemId("5b95877d-86c0-4cb7-a0cd-7660b348ae5d"); + transactionEntity.setStatus(TransactionStatus.StatusEnum.OPEN); + transactionEntity.setRole(BORROWING_PICKUP); + + var circulationItem = createCirculationItem(); + circulationItem.setStatus(org.folio.dcb.domain.dto.ItemStatus.builder().name(org.folio.dcb.domain.dto.ItemStatus.NameEnum.AWAITING_PICKUP).build()); + + MessageHeaders messageHeaders = getMessageHeaders(); + when(transactionRepository.findTransactionByRequestIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); + when(circulationItemService.fetchItemById(anyString())).thenReturn(circulationItem); + eventListener.handleRequestEvent(CHECK_IN_EVENT_SAMPLE, messageHeaders); + Mockito.verify(transactionRepository).save(any()); + } + + @Test + void handleCancelRequestTest() { + var transactionEntity = createTransactionEntity(); + transactionEntity.setRole(LENDER); + MessageHeaders messageHeaders = getMessageHeaders(); + when(transactionRepository.findTransactionByRequestIdAndStatusNotInClosed(any())).thenReturn(Optional.of(transactionEntity)); + eventListener.handleRequestEvent(REQUEST_CANCEL_EVENT_SAMPLE, messageHeaders); + Mockito.verify(transactionRepository).save(any()); + } + + @Test + void handleRequestCancelWithIncorrectDataTest() { + var transactionEntity = createTransactionEntity(); + transactionEntity.setRole(LENDER); + MessageHeaders messageHeaders = getMessageHeaders(); + assertDoesNotThrow(() -> eventListener.handleRequestEvent(null, messageHeaders)); + } + + private MessageHeaders getMessageHeaders() { + Map header = new HashMap<>(); + header.put(XOkapiHeaders.TENANT, TENANT.getBytes()); + return new MessageHeaders(header); + } +} diff --git a/src/test/java/org/folio/dcb/service/BaseLibraryServiceTest.java b/src/test/java/org/folio/dcb/service/BaseLibraryServiceTest.java index 25841f9d..05edb7d7 100644 --- a/src/test/java/org/folio/dcb/service/BaseLibraryServiceTest.java +++ b/src/test/java/org/folio/dcb/service/BaseLibraryServiceTest.java @@ -1,7 +1,7 @@ package org.folio.dcb.service; -import org.folio.dcb.domain.dto.CirculationItemRequest; +import org.folio.dcb.domain.dto.CirculationItem; import org.folio.dcb.domain.dto.ItemStatus; import org.folio.dcb.domain.dto.TransactionStatus; import org.folio.dcb.domain.dto.TransactionStatusResponse; @@ -99,7 +99,7 @@ void updateTransactionTestFromCheckedOutToCheckedIns() { var transactionEntity = createTransactionEntity(); transactionEntity.setStatus(TransactionStatus.StatusEnum.ITEM_CHECKED_OUT); transactionEntity.setRole(BORROWING_PICKUP); - when(circulationItemService.fetchItemById(any())).thenReturn(CirculationItemRequest.builder().status( + when(circulationItemService.fetchItemById(any())).thenReturn(CirculationItem.builder().status( ItemStatus.builder().name(ItemStatus.NameEnum.AVAILABLE).build()).build()); baseLibraryService.updateStatusByTransactionEntity(transactionEntity); Mockito.verify(transactionRepository, times(1)).save(transactionEntity); diff --git a/src/test/java/org/folio/dcb/service/TransactionAuditServiceTest.java b/src/test/java/org/folio/dcb/service/TransactionAuditServiceTest.java new file mode 100644 index 00000000..0f285daa --- /dev/null +++ b/src/test/java/org/folio/dcb/service/TransactionAuditServiceTest.java @@ -0,0 +1,52 @@ +package org.folio.dcb.service; + +import org.folio.dcb.domain.mapper.TransactionMapper; +import org.folio.dcb.repository.TransactionAuditRepository; +import org.folio.dcb.service.impl.TransactionAuditServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.folio.dcb.domain.dto.DcbTransaction.RoleEnum.LENDER; +import static org.folio.dcb.utils.EntityUtils.DCB_TRANSACTION_ID; +import static org.folio.dcb.utils.EntityUtils.createDcbTransactionByRole; +import static org.folio.dcb.utils.EntityUtils.createTransactionEntity; +import static org.folio.dcb.utils.EntityUtils.createTransactionAuditEntity; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionAuditServiceTest { + + @InjectMocks + private TransactionAuditServiceImpl transactionAuditService; + @Mock + private TransactionMapper transactionMapper; + @Mock + private TransactionAuditRepository transactionAuditRepository; + + @Test + void logTheErrorForExistedTransactionAuditTest() { + when(transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(any())) + .thenReturn(Optional.of(createTransactionAuditEntity())); + transactionAuditService.logErrorIfTransactionAuditExists(DCB_TRANSACTION_ID, "error_message"); + Mockito.verify(transactionMapper, times(0)).mapToEntity(any(), any()); + Mockito.verify(transactionAuditRepository, times(1)).save(any()); + } + @Test + void logTheErrorForNotExistedTransactionAuditTest() { + when(transactionMapper.mapToEntity(any(), any())).thenReturn(createTransactionEntity()); + when(transactionAuditRepository.findLatestTransactionAuditEntityByDcbTransactionId(any())) + .thenReturn(Optional.empty()); + transactionAuditService.logErrorIfTransactionAuditNotExists(DCB_TRANSACTION_ID, createDcbTransactionByRole(LENDER), "error_message"); + Mockito.verify(transactionMapper, times(1)).mapToEntity(any(), any()); + Mockito.verify(transactionAuditRepository, times(1)).save(any()); + } + +} diff --git a/src/test/java/org/folio/dcb/utils/EntityUtils.java b/src/test/java/org/folio/dcb/utils/EntityUtils.java index 21dedd4e..0c4ac366 100644 --- a/src/test/java/org/folio/dcb/utils/EntityUtils.java +++ b/src/test/java/org/folio/dcb/utils/EntityUtils.java @@ -3,6 +3,7 @@ import lombok.SneakyThrows; import org.folio.dcb.DcbApplication; import org.folio.dcb.client.feign.HoldingsStorageClient; +import org.folio.dcb.domain.dto.CirculationItem; import org.folio.dcb.domain.dto.CirculationRequest; import org.folio.dcb.domain.dto.DcbTransaction; import org.folio.dcb.domain.dto.DcbItem; @@ -11,6 +12,7 @@ import org.folio.dcb.domain.dto.TransactionStatusResponse; import org.folio.dcb.domain.dto.User; import org.folio.dcb.domain.dto.TransactionStatus; +import org.folio.dcb.domain.entity.TransactionAuditEntity; import org.folio.dcb.domain.entity.TransactionEntity; import org.folio.dcb.domain.dto.InventoryItem; import org.folio.dcb.domain.dto.UserGroupCollection; @@ -48,6 +50,8 @@ public class EntityUtils { public static String PICKUP_SERVICE_POINT_ID = "0da8c1e4-1c1f-4dd9-b189-70ba978b7d94"; public static String DCB_TRANSACTION_ID = "571b0a2c-8883-40b5-a449-d41fe6017082"; public static String CIRCULATION_REQUEST_ID = "571b0a2c-8883-40b5-a449-d41fe6017083"; + + public static String CIRCULATION_ITEM_REQUEST_ID = "571b0a2c-8883-40b5-a449-d41fe6017183"; public static String DCB_USER_TYPE = "dcb"; public static String DCB_TYPE_USER_ID = "910c512c-ebc5-40c6-96a5-a20bfd81e154"; public static String EXISTED_INVENTORY_ITEM_BARCODE = "INVENTORY_ITEM"; @@ -98,6 +102,12 @@ public static CirculationRequest createCirculationRequest() { .build(); } + public static CirculationItem createCirculationItem() { + return CirculationItem.builder() + .id(CIRCULATION_ITEM_REQUEST_ID) + .build(); + } + public static DcbPatron createDcbPatronWithExactPatronId(String patronId) { return DcbPatron.builder() .id(patronId) @@ -207,4 +217,13 @@ private static UserGroup createUserGroup() { .build(); } + public static TransactionAuditEntity createTransactionAuditEntity(){ + return TransactionAuditEntity.builder() + .id(UUID.randomUUID()) + .transactionId(UUID.randomUUID().toString()) + .action("UPDATE") + .before("") + .after("") + .build(); + } } diff --git a/src/test/resources/mappings/circulation-item.json b/src/test/resources/mappings/circulation-item.json index c9e2c2e4..ac299c0a 100644 --- a/src/test/resources/mappings/circulation-item.json +++ b/src/test/resources/mappings/circulation-item.json @@ -52,6 +52,32 @@ } } }, + { + "request": { + "method": "GET", + "url": "/circulation-item/items?query=id%3D%3D5b95877d-86c0-4cb7-a0cd-7660b348ae5a%20and%20barcode%3D%3DDCB_ITEM" + }, + "response": { + "status": 200, + "body": "{\n \"totalRecords\": 1,\n \"items\": [\n {\n \"id\": \"5b95877d-86c0-4cb7-a0cd-7660b348ae5a\",\n \"holdingsRecordId\": \"10cd3a5a-d36f-4c7a-bc4f-e1ae3cf820c9\",\n \"status\": {\n \"name\": \"In transit\",\n \"date\": \"2023-12-08T07:04:48.224+00:00\"\n },\n \"dcbItem\": true,\n \"materialTypeId\": \"1a54b431-2e4f-452d-9cae-9cee66c9a892\",\n \"permanentLoanTypeId\": \"4dec5417-0765-4767-bed6-b363a2d7d4e2\",\n \"instanceTitle\": \"BATTLE OF WAR\",\n \"barcode\": \"DCB_ITEM\",\n \"pickupLocation\": \"3a40852d-49fd-4df2-a1f9-006e2641a6e9\",\n \"effectiveLocationId\": \"9d1b77e8-f02e-4b7f-b296-3f2042ddac54\"\n }\n ]\n}", + "headers": { + "Content-Type": "application/json" + } + } + }, + { + "request": { + "method": "GET", + "url": "/circulation-item/items?query=id%3D%3D5b95877d-86c0-4cb7-a0cd-7660b348ae5a%20and%20barcode%3D%3DnewItem" + }, + "response": { + "status": 200, + "body": "{\n \"totalRecords\": 0,\n \"items\": []\n}", + "headers": { + "Content-Type": "application/json" + } + } + }, { "request": { "method": "GET", diff --git a/src/test/resources/mappings/inventory.json b/src/test/resources/mappings/inventory.json index 27491763..ba92db6d 100644 --- a/src/test/resources/mappings/inventory.json +++ b/src/test/resources/mappings/inventory.json @@ -38,6 +38,19 @@ } } }, + { + "request": { + "method": "GET", + "url": "/item-storage/items?query=barcode%3D%3DnewItem" + }, + "response": { + "status": 200, + "body": "{\"users\": [], \"totalRecords\": 0, \"resultInfo\": {\"totalRecords\": 0, \"facets\": [],\"diagnostics\": []}}", + "headers": { + "Content-Type": "application/json" + } + } + }, { "request": { "method": "GET", diff --git a/src/test/resources/mockdata/kafka/check_in.json b/src/test/resources/mockdata/kafka/check_in.json index b7042517..0a3afeb8 100644 --- a/src/test/resources/mockdata/kafka/check_in.json +++ b/src/test/resources/mockdata/kafka/check_in.json @@ -1,18 +1,19 @@ { "id": "091a277f-db39-4f3d-bed8-a43e145bb1c6", - "type": "CREATED", + "type": "UPDATED", "tenant": "diku", "timestamp": 1695390940007, "data": { "new": { - "id": "d03028bb-0116-4fbf-acd2-16397365b650", - "occurredDateTime": "2023-09-22T13:55:39.868+00:00", - "itemId": "1714f71f-b845-444b-a79e-a577487a6f7d", - "itemStatusPriorToCheckIn": "In transit", - "requestQueueSize": 1, - "itemLocationId": "fcd64ce1-6995-48f0-840e-89ffa2288371", - "servicePointId": "7c5abc9f-f3d7-4856-b8d7-6712462ca007", - "performedByUserId": "f4898e8c-ef61-5c0d-83a7-f5232c60e263" + "id": "8f77e07f-217f-4dec-bb95-cee23575d712", + "requestLevel": "Item", + "requestType": "Hold", + "requestDate": "2023-11-27T10:47:18.976+00:00", + "requesterId": "5d2625ef-81eb-4e61-a8a9-87c94ba3764e", + "instanceId": "9d1b77e4-f02e-4b7f-b296-3f2042ddac54", + "holdingsRecordId": "10cd3a5a-d36f-4c7a-bc4f-e1ae3cf820c9", + "itemId": "5d2625ef-81eb-4e61-a8a9-87c94ba3764e", + "status": "Open - Awaiting pickup" } } } diff --git a/src/test/resources/mockdata/kafka/loan_check_in.json b/src/test/resources/mockdata/kafka/loan_check_in.json new file mode 100644 index 00000000..2a03146f --- /dev/null +++ b/src/test/resources/mockdata/kafka/loan_check_in.json @@ -0,0 +1,32 @@ +{ + "id":"91c6aa83-907d-466a-9e24-7b3615578f41", + "type":"UPDATED", + "tenant":"diku", + "timestamp":1698749309768, + "data":{ + "new":{ + "id":"246de29d-3048-4c36-9bac-63639636f2ac", + "userId":"2205005b-ca51-4a04-87fd-938eefa8f6de", + "itemId":"8db107f5-12aa-479f-9c07-39e7c9cf2e4d", + "itemEffectiveLocationIdAtCheckOut":"184aae84-a5bf-4c6a-85ba-4a7c73026cd5", + "status":{ + "name":"Open" + }, + "loanDate":"2023-10-31T10:48:26.011Z", + "dueDate":"2023-12-30T23:59:59.000+00:00", + "action":"checkedin", + "itemStatus":"In transit", + "loanPolicyId":"d9cd0bed-1b49-4b5e-a7bd-064b8d177231", + "checkoutServicePointId":"7c5abc9f-f3d7-4856-b8d7-6712462ca007", + "patronGroupIdAtCheckout":"3684a786-6671-4268-8ed0-9db82ebca60b", + "overdueFinePolicyId":"cd3f6cac-fa17-4079-9fae-2fb28e521412", + "lostItemPolicyId":"ed892c0e-52e0-4cd9-8133-c0ef07b4a709", + "metadata":{ + "createdDate":"2023-10-31T10:48:29.717+00:00", + "createdByUserId":"a8030331-6d17-5206-afb7-f81c23edee16", + "updatedDate":"2023-10-31T10:48:29.717+00:00", + "updatedByUserId":"a8030331-6d17-5206-afb7-f81c23edee16" + } + } + } +}