diff --git a/.gitignore b/.gitignore
index c6f99c4b..a6b50873 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ target/
!**/src/test/**/target/
bin/
.checkstyle
+.jpb
### STS ###
.apt_generated
diff --git a/pom.xml b/pom.xml
index e71bedb0..079e8d45 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 2.7.0
+ 2.7.3
@@ -20,6 +20,10 @@
UTF-8
UTF-8
${project.basedir}/src/main/resources/swagger.api/mod-entities-links.yaml
+
+ src/main/java/org/folio/entlinks/EntityLinksApplication.java,
+ src/main/java/org/folio/entlinks/model/**
+
4.1.0
1.5.2.Final
@@ -29,6 +33,7 @@
5.4.0
1.0.1
+ 2.18.0
@@ -55,6 +60,11 @@
com.fasterxml.jackson.datatype
jackson-datatype-jdk8
+
+ com.vladmihalcea
+ hibernate-types-55
+ ${hibernate-types.version}
+
org.projectlombok
lombok
@@ -232,7 +242,7 @@
ApiUtil.java
true
- error=org.folio.tenant.domain.dto.Error
+ error=org.folio.tenant.domain.dto.Errors
true
diff --git a/src/main/java/org/folio/entlinks/controller/ApiErrorHandler.java b/src/main/java/org/folio/entlinks/controller/ApiErrorHandler.java
new file mode 100644
index 00000000..a4bb9c7d
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/controller/ApiErrorHandler.java
@@ -0,0 +1,133 @@
+package org.folio.entlinks.controller;
+
+import static java.util.Collections.emptyList;
+import static org.apache.logging.log4j.Level.DEBUG;
+import static org.apache.logging.log4j.Level.WARN;
+import static org.folio.entlinks.model.type.ErrorCode.UNKNOWN_ERROR;
+import static org.folio.entlinks.model.type.ErrorCode.VALIDATION_ERROR;
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
+import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY;
+
+import java.util.List;
+import java.util.Optional;
+import javax.validation.ConstraintViolationException;
+import lombok.extern.log4j.Log4j2;
+import org.apache.logging.log4j.Level;
+import org.folio.entlinks.exception.RequestBodyValidationException;
+import org.folio.entlinks.model.type.ErrorCode;
+import org.folio.tenant.domain.dto.Error;
+import org.folio.tenant.domain.dto.Errors;
+import org.folio.tenant.domain.dto.Parameter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+@Log4j2
+@RestControllerAdvice
+public class ApiErrorHandler {
+
+ private static ResponseEntity buildResponseEntity(Exception e, HttpStatus status, ErrorCode code) {
+ var errors = new Errors()
+ .errors(List.of(new Error()
+ .message(e.getMessage())
+ .type(e.getClass().getSimpleName())
+ .code(code.getValue())))
+ .totalRecords(1);
+ return buildResponseEntity(errors, status);
+ }
+
+ private static ResponseEntity buildResponseEntity(Errors errorResponse, HttpStatus status) {
+ return ResponseEntity.status(status).body(errorResponse);
+ }
+
+ private static void logException(Level logLevel, Exception e) {
+ log.log(logLevel, "Handling e", e);
+ }
+
+ private static Errors buildValidationError(Exception e, List parameters) {
+ var error = new Error()
+ .type(e.getClass().getSimpleName())
+ .code(VALIDATION_ERROR.getValue())
+ .message(e.getMessage())
+ .parameters(parameters);
+ return new Errors().errors(List.of(error)).totalRecords(1);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGlobalExceptions(Exception e) {
+ logException(WARN, e);
+ return buildResponseEntity(e, INTERNAL_SERVER_ERROR, UNKNOWN_ERROR);
+ }
+
+ @ExceptionHandler(RequestBodyValidationException.class)
+ public ResponseEntity handleRequestValidationException(RequestBodyValidationException e) {
+ logException(DEBUG, e);
+ var errorResponse = buildValidationError(e, e.getInvalidParameters());
+ return buildResponseEntity(errorResponse, UNPROCESSABLE_ENTITY);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ logException(DEBUG, e);
+ var errors = Optional.of(e.getBindingResult())
+ .map(org.springframework.validation.Errors::getAllErrors)
+ .orElse(emptyList())
+ .stream()
+ .map(error -> new Error()
+ .message(error.getDefaultMessage())
+ .code(VALIDATION_ERROR.getValue())
+ .type(MethodArgumentNotValidException.class.getSimpleName())
+ .addParametersItem(new Parameter()
+ .key(((FieldError) error).getField())
+ .value(String.valueOf(((FieldError) error).getRejectedValue()))))
+ .toList();
+
+ var errorResponse = new Errors().errors(errors).totalRecords(errors.size());
+ return buildResponseEntity(errorResponse, UNPROCESSABLE_ENTITY);
+ }
+
+ @ExceptionHandler(ConstraintViolationException.class)
+ public ResponseEntity handleConstraintViolation(ConstraintViolationException e) {
+ logException(DEBUG, e);
+ var errors = e.getConstraintViolations().stream()
+ .map(constraintViolation -> new Error()
+ .message(String.format("%s %s", constraintViolation.getPropertyPath(), constraintViolation.getMessage()))
+ .code(VALIDATION_ERROR.getValue())
+ .type(ConstraintViolationException.class.getSimpleName()))
+ .toList();
+
+ var errorResponse = new Errors().errors(errors).totalRecords(errors.size());
+ return buildResponseEntity(errorResponse, BAD_REQUEST);
+ }
+
+ @ExceptionHandler({
+ MethodArgumentTypeMismatchException.class,
+ MissingServletRequestParameterException.class,
+ IllegalArgumentException.class
+ })
+ public ResponseEntity handleValidationException(Exception e) {
+ logException(DEBUG, e);
+ return buildResponseEntity(e, BAD_REQUEST, VALIDATION_ERROR);
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ public ResponseEntity handlerHttpMessageNotReadableException(HttpMessageNotReadableException e) {
+ return Optional.ofNullable(e.getCause())
+ .map(Throwable::getCause)
+ .filter(IllegalArgumentException.class::isInstance)
+ .map(IllegalArgumentException.class::cast)
+ .map(this::handleValidationException)
+ .orElseGet(() -> {
+ logException(DEBUG, e);
+ return buildResponseEntity(e, BAD_REQUEST, VALIDATION_ERROR);
+ });
+ }
+
+}
diff --git a/src/main/java/org/folio/entlinks/controller/InstanceLinksController.java b/src/main/java/org/folio/entlinks/controller/InstanceLinksController.java
new file mode 100644
index 00000000..995f175e
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/controller/InstanceLinksController.java
@@ -0,0 +1,22 @@
+package org.folio.entlinks.controller;
+
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.folio.entlinks.service.InstanceLinkService;
+import org.folio.qm.domain.dto.InstanceLinkDtoCollection;
+import org.folio.qm.rest.resource.InstanceLinksApi;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class InstanceLinksController implements InstanceLinksApi {
+
+ private final InstanceLinkService instanceLinkService;
+
+ @Override
+ public ResponseEntity updateInstanceLinks(UUID instanceId, InstanceLinkDtoCollection instanceLinkCollection) {
+ instanceLinkService.updateInstanceLinks(instanceId, instanceLinkCollection);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/src/main/java/org/folio/entlinks/exception/BaseException.java b/src/main/java/org/folio/entlinks/exception/BaseException.java
new file mode 100644
index 00000000..39d6fc36
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/exception/BaseException.java
@@ -0,0 +1,24 @@
+package org.folio.entlinks.exception;
+
+import lombok.Getter;
+import org.folio.entlinks.model.type.ErrorCode;
+
+/**
+ * Base exception class that is used for all exceptional situations
+ */
+@Getter
+public abstract class BaseException extends RuntimeException {
+
+ private final ErrorCode errorCode;
+
+ /**
+ * Initialize exception with provided message and error code.
+ *
+ * @param message exception message
+ * @param errorCode exception code {@link ErrorCode}
+ */
+ protected BaseException(String message, ErrorCode errorCode) {
+ super(message);
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/org/folio/entlinks/exception/RequestBodyValidationException.java b/src/main/java/org/folio/entlinks/exception/RequestBodyValidationException.java
new file mode 100644
index 00000000..8e3cbf54
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/exception/RequestBodyValidationException.java
@@ -0,0 +1,27 @@
+package org.folio.entlinks.exception;
+
+import java.util.List;
+import lombok.Getter;
+import org.folio.entlinks.model.type.ErrorCode;
+import org.folio.tenant.domain.dto.Parameter;
+
+/**
+ * Exception for situations when request body is invalid
+ */
+@Getter
+public class RequestBodyValidationException extends BaseException {
+
+ private final transient List invalidParameters;
+
+ /**
+ * Initialize exception with provided message, error code and invalid parameters.
+ *
+ * @param message exception message
+ * @param invalidParameters list of invalid parameters {@link Parameter}
+ */
+ public RequestBodyValidationException(String message, List invalidParameters) {
+ super(message, ErrorCode.VALIDATION_ERROR);
+ this.invalidParameters = invalidParameters;
+ }
+
+}
diff --git a/src/main/java/org/folio/entlinks/model/converter/InstanceLinkMapper.java b/src/main/java/org/folio/entlinks/model/converter/InstanceLinkMapper.java
new file mode 100644
index 00000000..cb544aaf
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/model/converter/InstanceLinkMapper.java
@@ -0,0 +1,13 @@
+package org.folio.entlinks.model.converter;
+
+import org.folio.entlinks.model.entity.InstanceLink;
+import org.folio.qm.domain.dto.InstanceLinkDto;
+import org.mapstruct.Mapper;
+
+@Mapper(componentModel = "spring")
+public interface InstanceLinkMapper {
+
+ InstanceLinkDto convert(InstanceLink source);
+
+ InstanceLink convert(InstanceLinkDto source);
+}
diff --git a/src/main/java/org/folio/entlinks/model/entity/InstanceLink.java b/src/main/java/org/folio/entlinks/model/entity/InstanceLink.java
new file mode 100644
index 00000000..4227e6af
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/model/entity/InstanceLink.java
@@ -0,0 +1,81 @@
+package org.folio.entlinks.model.entity;
+
+import com.vladmihalcea.hibernate.type.array.ListArrayType;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+import org.hibernate.Hibernate;
+import org.hibernate.annotations.Type;
+import org.hibernate.annotations.TypeDef;
+
+@Getter
+@Setter
+@ToString
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Entity
+@Table(name = "instance_link", indexes = {
+ @Index(name = "idx_instancelink_authority_id", columnList = "authority_id"),
+ @Index(name = "idx_instancelink_instance_id", columnList = "instance_id")
+})
+@TypeDef(name = "list-array", typeClass = ListArrayType.class)
+public class InstanceLink {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id", nullable = false)
+ private Long id;
+
+ @NotNull
+ @Column(name = "authority_id", nullable = false)
+ private UUID authorityId;
+
+ @NotNull
+ @Column(name = "authority_natural_id", nullable = false, length = 100)
+ private String authorityNaturalId;
+
+ @NotNull
+ @Column(name = "instance_id", nullable = false)
+ private UUID instanceId;
+
+ @Column(name = "bib_record_tag", length = 3)
+ private String bibRecordTag;
+
+ @Type(type = "list-array")
+ @Column(name = "bib_record_subfields")
+ private List bibRecordSubfields;
+
+ @Override
+ public int hashCode() {
+ return getClass().hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) { return true; }
+ if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { return false; }
+ InstanceLink instanceLink = (InstanceLink) o;
+ return id != null && Objects.equals(id, instanceLink.id);
+ }
+
+ public boolean isSameLink(InstanceLink link) {
+ return authorityId.equals(link.authorityId)
+ && instanceId.equals(link.instanceId)
+ && bibRecordTag.equals(link.bibRecordTag);
+ }
+}
diff --git a/src/main/java/org/folio/entlinks/model/type/ErrorCode.java b/src/main/java/org/folio/entlinks/model/type/ErrorCode.java
new file mode 100644
index 00000000..2aeeac17
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/model/type/ErrorCode.java
@@ -0,0 +1,14 @@
+package org.folio.entlinks.model.type;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum ErrorCode {
+
+ VALIDATION_ERROR("validation"),
+ UNKNOWN_ERROR("unknown");
+
+ private final String value;
+}
diff --git a/src/main/java/org/folio/entlinks/repository/InstanceLinkRepository.java b/src/main/java/org/folio/entlinks/repository/InstanceLinkRepository.java
new file mode 100644
index 00000000..d30b0910
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/repository/InstanceLinkRepository.java
@@ -0,0 +1,12 @@
+package org.folio.entlinks.repository;
+
+import java.util.List;
+import java.util.UUID;
+import org.folio.entlinks.model.entity.InstanceLink;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface InstanceLinkRepository extends JpaRepository {
+
+ List findByInstanceId(UUID instanceId);
+
+}
diff --git a/src/main/java/org/folio/entlinks/service/InstanceLinkService.java b/src/main/java/org/folio/entlinks/service/InstanceLinkService.java
new file mode 100644
index 00000000..706c635c
--- /dev/null
+++ b/src/main/java/org/folio/entlinks/service/InstanceLinkService.java
@@ -0,0 +1,73 @@
+package org.folio.entlinks.service;
+
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.folio.entlinks.exception.RequestBodyValidationException;
+import org.folio.entlinks.model.converter.InstanceLinkMapper;
+import org.folio.entlinks.model.entity.InstanceLink;
+import org.folio.entlinks.repository.InstanceLinkRepository;
+import org.folio.qm.domain.dto.InstanceLinkDto;
+import org.folio.qm.domain.dto.InstanceLinkDtoCollection;
+import org.folio.tenant.domain.dto.Parameter;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class InstanceLinkService {
+
+ private final InstanceLinkRepository repository;
+ private final InstanceLinkMapper mapper;
+
+ @Transactional
+ public void updateInstanceLinks(UUID instanceId, @NotNull InstanceLinkDtoCollection instanceLinkCollection) {
+ var links = instanceLinkCollection.getLinks();
+ validateLinks(instanceId, links);
+ var incomingLinks = links.stream().map(mapper::convert).toList();
+ var existedLinks = repository.findByInstanceId(instanceId);
+
+ var linksToDelete = subtract(existedLinks, incomingLinks);
+ var linksToCreate = subtract(incomingLinks, existedLinks);
+ repository.deleteAllInBatch(linksToDelete);
+ repository.saveAll(linksToCreate);
+ }
+
+ private List subtract(Collection source, Collection target) {
+ return new LinkedHashSet<>(source).stream()
+ .filter(t -> target.stream().noneMatch(link -> link.isSameLink(t)))
+ .toList();
+ }
+
+ private void validateLinks(UUID instanceId, List links) {
+ validateInstanceId(instanceId, links);
+ validateSubfields(links);
+ }
+
+ private void validateSubfields(List links) {
+ var invalidSubfields = links.stream()
+ .map(InstanceLinkDto::getBibRecordSubfields)
+ .flatMap(List::stream)
+ .filter(subfield -> subfield.length() != 1)
+ .map(invalidSubfield -> new Parameter().key("bibRecordSubfields").value(invalidSubfield))
+ .toList();
+
+ if (!invalidSubfields.isEmpty()) {
+ throw new RequestBodyValidationException("Max Bib record subfield length is 1", invalidSubfields);
+ }
+ }
+
+ private void validateInstanceId(UUID instanceId, List links) {
+ var invalidParams = links.stream()
+ .map(InstanceLinkDto::getInstanceId)
+ .filter(targetId -> !targetId.equals(instanceId))
+ .map(targetId -> new Parameter().key("instanceId").value(targetId.toString()))
+ .toList();
+ if (!invalidParams.isEmpty()) {
+ throw new RequestBodyValidationException("Link should have instanceId = " + instanceId, invalidParams);
+ }
+ }
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 3857fda7..6df1e51e 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -5,14 +5,18 @@ spring:
continue-on-error: true
password: ${DB_PASSWORD:folio_admin}
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_DATABASE:okapi_modules}
- username: ${DB_USERNAME:folio_admin},
+ username: ${DB_USERNAME:folio_admin}
+ liquibase:
+ change-log: classpath:db/changelog/changelog-master.xml
+ jpa:
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQL10Dialect
jackson:
default-property-inclusion: non_null
deserialization:
fail-on-unknown-properties: false
accept-single-value-as-array: true
- liquibase:
- change-log: classpath:changelog/changelog-master.xml
folio:
environment: ${ENV:folio}
diff --git a/src/main/resources/db/changelog/changelog-master.xml b/src/main/resources/db/changelog/changelog-master.xml
index 75badf60..ad389455 100644
--- a/src/main/resources/db/changelog/changelog-master.xml
+++ b/src/main/resources/db/changelog/changelog-master.xml
@@ -4,5 +4,5 @@
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
-
+
diff --git a/src/main/resources/db/changelog/changes/v1.0/initial_schema.xml b/src/main/resources/db/changelog/changes/v1.0/initial_schema.xml
index 0e799c61..1c938fa8 100644
--- a/src/main/resources/db/changelog/changes/v1.0/initial_schema.xml
+++ b/src/main/resources/db/changelog/changes/v1.0/initial_schema.xml
@@ -4,4 +4,60 @@
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
+
+
+
+
+
+
+
+ Create instance_link table
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create B-tree index for authority_id in instance_link
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create B-tree index for instance_id in instance_link
+
+
+
+
+
+
+
diff --git a/src/main/resources/swagger.api/mod-entities-links.yaml b/src/main/resources/swagger.api/mod-entities-links.yaml
index 94eb4bf6..838c8d65 100644
--- a/src/main/resources/swagger.api/mod-entities-links.yaml
+++ b/src/main/resources/swagger.api/mod-entities-links.yaml
@@ -8,8 +8,111 @@ servers:
- url: https://localhost:8081
paths:
- /stub:
- get:
+ /links/instances/{instanceId}:
+ put:
+ description: Update links collection related to Instance
+ operationId: updateInstanceLinks
+ tags:
+ - instance-links
+ parameters:
+ - name: instanceId
+ in: path
+ required: true
+ description: UUID of the Instance that is related to the MARC record
+ schema:
+ $ref: "#/components/schemas/uuid"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/instanceLinkDtoCollection"
+ required: true
responses:
- "200":
- description: stub
+ "202":
+ description: The links were updated
+ '400':
+ $ref: '#/components/responses/badRequestResponse'
+ '422':
+ $ref: '#/components/responses/unprocessableEntityResponse'
+
+components:
+ schemas:
+ instanceLinkDtoCollection:
+ type: object
+ title: Collection of instance links
+ description: Collection of instance links
+ properties:
+ links:
+ type: array
+ items:
+ $ref: '#/components/schemas/instanceLinkDto'
+ totalRecords:
+ type: integer
+ readOnly: true
+ description: Total amount of notes
+ required:
+ - links
+
+ instanceLinkDto:
+ type: object
+ title: Collection of instance links
+ description: Collection of instance links
+ properties:
+ id:
+ type: integer
+ description: Unique generated identifier for the link
+ authorityId:
+ $ref: '#/components/schemas/uuid'
+ description: ID of the Authority record
+ authorityNaturalId:
+ type: string
+ description: Natural ID of the Authority record
+ instanceId:
+ $ref: '#/components/schemas/uuid'
+ description: ID of the Instance record
+ bibRecordTag:
+ type: string
+ pattern: '^[0-9]{3}$'
+ bibRecordSubfields:
+ type: array
+ minItems: 1
+ maxItems: 100
+ items:
+ type: string
+ required:
+ - authorityId
+ - authorityNaturalId
+ - instanceId
+ - bibRecordTag
+ - bibRecordSubfields
+
+ uuid:
+ type: string
+ format: uuid
+
+ errorResponse:
+ type: object
+ example:
+ Error:
+ value:
+ errors:
+ - message: may not be null
+ type: 1
+ code: -1
+ parameters:
+ - key: moduleTo
+ value: null
+
+ responses:
+ badRequestResponse:
+ description: Validation errors.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/errorResponse"
+ unprocessableEntityResponse:
+ description: Validation error for the request.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/errorResponse'
diff --git a/src/test/java/org/folio/entlinks/controller/InstanceLinksIT.java b/src/test/java/org/folio/entlinks/controller/InstanceLinksIT.java
new file mode 100644
index 00000000..2049a418
--- /dev/null
+++ b/src/test/java/org/folio/entlinks/controller/InstanceLinksIT.java
@@ -0,0 +1,269 @@
+package org.folio.entlinks.controller;
+
+import static java.util.Collections.emptyList;
+import static java.util.UUID.randomUUID;
+import static org.folio.support.TestUtils.linksDto;
+import static org.folio.support.TestUtils.linksDtoCollection;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.List;
+import java.util.stream.Stream;
+import lombok.SneakyThrows;
+import org.folio.entlinks.model.type.ErrorCode;
+import org.folio.qm.domain.dto.InstanceLinkDto;
+import org.folio.support.TestUtils.Link;
+import org.folio.support.base.IntegrationTestBase;
+import org.folio.support.types.IntegrationTest;
+import org.hamcrest.Matcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.test.web.servlet.ResultMatcher;
+
+@IntegrationTest
+class InstanceLinksIT extends IntegrationTestBase {
+
+ public static Stream requiredFieldMissingProvider() {
+ return Stream.of(
+ arguments("instanceId",
+ new InstanceLinkDto()
+ .authorityId(randomUUID()).authorityNaturalId("id")
+ .bibRecordTag("100").bibRecordSubfields(List.of("a"))
+ ),
+ arguments("authorityId",
+ new InstanceLinkDto().instanceId(randomUUID())
+ .authorityNaturalId("id")
+ .bibRecordTag("100").bibRecordSubfields(List.of("a"))
+ ),
+ arguments("authorityNaturalId",
+ new InstanceLinkDto().instanceId(randomUUID())
+ .authorityId(randomUUID())
+ .bibRecordTag("100").bibRecordSubfields(List.of("a"))
+ ),
+ arguments("bibRecordTag",
+ new InstanceLinkDto().instanceId(randomUUID())
+ .authorityId(randomUUID()).authorityNaturalId("id")
+ .bibRecordSubfields(List.of("a"))
+ )
+ );
+ }
+
+ private static ResultMatcher errorTotalMatch(int errorTotal) {
+ return jsonPath("$.total_records", is(errorTotal));
+ }
+
+ @Test
+ @SuppressWarnings("java:S2699")
+ void updateInstanceLinks_positive_saveIncomingLinks_whenAnyExist() {
+ var instanceId = randomUUID();
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0), Link.of(1, 1)));
+ doPut("/links/instances/{id}", incomingLinks, instanceId);
+
+// doGet("/links/instances/{id}", instanceId); TODO: uncomment and add body checks when GET endpoint implemented
+ }
+
+ @Test
+ @SuppressWarnings("java:S2699")
+ void updateInstanceLinks_positive_deleteAllLinks_whenIncomingIsEmpty() {
+ var instanceId = randomUUID();
+ var existedLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0), Link.of(1, 1)));
+ doPut("/links/instances/{id}", existedLinks, instanceId);
+
+ var incomingLinks = linksDtoCollection(emptyList());
+ doPut("/links/instances/{id}", incomingLinks, instanceId);
+
+// doGet("/links/instances/{id}", instanceId); TODO: uncomment and add body checks when GET endpoint implemented
+ }
+
+ @Test
+ @SuppressWarnings("java:S2699")
+ void updateInstanceLinks_positive_deleteAllExistedAndSaveAllIncomingLinks() {
+ var instanceId = randomUUID();
+ var existedLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 2),
+ Link.of(3, 3)
+ ));
+ doPut("/links/instances/{id}", existedLinks, instanceId);
+
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 1),
+ Link.of(1, 0),
+ Link.of(2, 3),
+ Link.of(3, 2)
+ ));
+ doPut("/links/instances/{id}", incomingLinks, instanceId);
+
+// doGet("/links/instances/{id}", instanceId); TODO: uncomment and add body checks when GET endpoint implemented
+ }
+
+ @Test
+ @SuppressWarnings("java:S2699")
+ void updateInstanceLinks_positive_saveOnlyNewLinks() {
+ var instanceId = randomUUID();
+ var existedLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1)
+ ));
+ doPut("/links/instances/{id}", existedLinks, instanceId);
+
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 2),
+ Link.of(3, 3)
+ ));
+ doPut("/links/instances/{id}", incomingLinks, instanceId);
+
+// doGet("/links/instances/{id}", instanceId); TODO: uncomment and add body checks when GET endpoint implemented
+ }
+
+ @Test
+ @SuppressWarnings("java:S2699")
+ void updateInstanceLinks_positive_deleteAndSaveLinks_whenHaveDifference() {
+ var instanceId = randomUUID();
+ var existedLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 2),
+ Link.of(3, 3)
+ ));
+ doPut("/links/instances/{id}", existedLinks, instanceId);
+
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 3),
+ Link.of(3, 2)
+ ));
+ doPut("/links/instances/{id}", incomingLinks, instanceId);
+
+// doGet("/links/instances/{id}", instanceId); TODO: uncomment and add body checks when GET endpoint implemented
+ }
+
+ @Test
+ @SneakyThrows
+ void updateInstanceLinks_negative_whenInstanceIdIsNotSameForIncomingLinks() {
+ var instanceId = randomUUID();
+ var incomingLinks = linksDtoCollection(linksDto(randomUUID(),
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 3),
+ Link.of(3, 2)
+ ));
+
+ tryPut("/links/instances/{id}", incomingLinks, instanceId)
+ .andExpect(status().isUnprocessableEntity())
+ .andExpect(errorTotalMatch(1))
+ .andExpect(errorTypeMatch(is("RequestBodyValidationException")))
+ .andExpect(errorCodeMatch(is(ErrorCode.VALIDATION_ERROR.getValue())))
+ .andExpect(errorMessageMatch(containsString("Link should have instanceId = " + instanceId)));
+ }
+
+ @Test
+ @SneakyThrows
+ void updateInstanceLinks_negative_whenInstanceIdIsNotUuid() {
+ var invalidInstanceId = "1111";
+ var incomingLinks = linksDtoCollection(emptyList());
+
+ tryPut("/links/instances/{id}", incomingLinks, invalidInstanceId)
+ .andExpect(status().isBadRequest())
+ .andExpect(errorTotalMatch(1))
+ .andExpect(errorTypeMatch(is("MethodArgumentTypeMismatchException")))
+ .andExpect(errorCodeMatch(is(ErrorCode.VALIDATION_ERROR.getValue())))
+ .andExpect(errorMessageMatch(containsString("Invalid UUID string")));
+ }
+
+ @Test
+ @SneakyThrows
+ void updateInstanceLinks_negative_whenBodyIsEmpty() {
+ var instanceId = randomUUID();
+
+ tryPut("/links/instances/{id}", null, instanceId)
+ .andExpect(status().isBadRequest())
+ .andExpect(errorTotalMatch(1))
+ .andExpect(errorTypeMatch(is("HttpMessageNotReadableException")))
+ .andExpect(errorCodeMatch(is(ErrorCode.VALIDATION_ERROR.getValue())))
+ .andExpect(errorMessageMatch(containsString("Required request body is missing")));
+ }
+
+ @Test
+ @SneakyThrows
+ void updateInstanceLinks_negative_whenBibRecordSubfieldsIsEmpty() {
+ var instanceId = randomUUID();
+
+ var incomingLinks = linksDtoCollection(List.of(new InstanceLinkDto()
+ .instanceId(randomUUID()).authorityId(randomUUID())
+ .authorityNaturalId("id").bibRecordTag("100")
+ ));
+
+ tryPut("/links/instances/{id}", incomingLinks, instanceId)
+ .andExpect(status().isUnprocessableEntity())
+ .andExpect(errorTotalMatch(1))
+ .andExpect(errorTypeMatch(is("MethodArgumentNotValidException")))
+ .andExpect(errorCodeMatch(is(ErrorCode.VALIDATION_ERROR.getValue())))
+ .andExpect(errorMessageMatch(containsString("size must be between 1 and 100")))
+ .andExpect(errorParameterMatch(is("links[0].bibRecordSubfields")));
+ }
+
+ @Test
+ @SneakyThrows
+ void updateInstanceLinks_negative_whenBibRecordSubfieldsValuesHaveMoreThenOneChar() {
+ var instanceId = randomUUID();
+
+ var incomingLinks = linksDtoCollection(List.of(new InstanceLinkDto()
+ .instanceId(instanceId).authorityId(randomUUID())
+ .authorityNaturalId("id").bibRecordTag("100")
+ .bibRecordSubfields(List.of("aa", "bb", "11"))
+ ));
+
+ tryPut("/links/instances/{id}", incomingLinks, instanceId)
+ .andExpect(status().isUnprocessableEntity())
+ .andExpect(errorTotalMatch(1))
+ .andExpect(errorTypeMatch(is("RequestBodyValidationException")))
+ .andExpect(errorCodeMatch(is(ErrorCode.VALIDATION_ERROR.getValue())))
+ .andExpect(errorMessageMatch(containsString("Max Bib record subfield length is 1")))
+ .andExpect(errorParameterMatch(is("bibRecordSubfields")));
+ }
+
+ @SneakyThrows
+ @MethodSource("requiredFieldMissingProvider")
+ @ParameterizedTest(name = "[{index}] missing {0}")
+ void updateInstanceLinks_negative_whenRequiredFieldIsMissing(String missingField, InstanceLinkDto invalidLink) {
+ var instanceId = randomUUID();
+ var incomingLinks = linksDtoCollection(List.of(invalidLink));
+
+ tryPut("/links/instances/{id}", incomingLinks, instanceId)
+ .andExpect(status().isUnprocessableEntity())
+ .andExpect(errorTotalMatch(1))
+ .andExpect(errorTypeMatch(is("MethodArgumentNotValidException")))
+ .andExpect(errorCodeMatch(is(ErrorCode.VALIDATION_ERROR.getValue())))
+ .andExpect(errorMessageMatch(containsString("must not be null")))
+ .andExpect(errorParameterMatch(is("links[0]." + missingField)));
+ }
+
+ private ResultMatcher errorParameterMatch(Matcher errorMessageMatcher) {
+ return jsonPath("$.errors.[0].parameters.[0].key", errorMessageMatcher);
+ }
+
+ private ResultMatcher errorTypeMatch(Matcher errorMessageMatcher) {
+ return jsonPath("$.errors.[0].type", errorMessageMatcher);
+ }
+
+ private ResultMatcher errorCodeMatch(Matcher errorMessageMatcher) {
+ return jsonPath("$.errors.[0].code", errorMessageMatcher);
+ }
+
+ private ResultMatcher errorMessageMatch(Matcher errorMessageMatcher) {
+ return jsonPath("$.errors.[0].message", errorMessageMatcher);
+ }
+
+}
diff --git a/src/test/java/org/folio/entlinks/service/InstanceLinkServiceTest.java b/src/test/java/org/folio/entlinks/service/InstanceLinkServiceTest.java
new file mode 100644
index 00000000..c3d8fa01
--- /dev/null
+++ b/src/test/java/org/folio/entlinks/service/InstanceLinkServiceTest.java
@@ -0,0 +1,222 @@
+package org.folio.entlinks.service;
+
+import static java.util.Collections.emptyList;
+import static java.util.UUID.randomUUID;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.from;
+import static org.folio.support.TestUtils.links;
+import static org.folio.support.TestUtils.linksDto;
+import static org.folio.support.TestUtils.linksDtoCollection;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import org.folio.entlinks.exception.RequestBodyValidationException;
+import org.folio.entlinks.model.converter.InstanceLinkMapperImpl;
+import org.folio.entlinks.model.entity.InstanceLink;
+import org.folio.entlinks.repository.InstanceLinkRepository;
+import org.folio.support.TestUtils.Link;
+import org.folio.support.types.UnitTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@UnitTest
+@ExtendWith(MockitoExtension.class)
+class InstanceLinkServiceTest {
+
+ @Mock
+ private InstanceLinkRepository repository;
+
+ private InstanceLinkService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new InstanceLinkService(repository, new InstanceLinkMapperImpl());
+ }
+
+ @Test
+ void updateInstanceLinks_positive_saveIncomingLinks_whenAnyExist() {
+ var instanceId = randomUUID();
+ var existedLinks = Collections.emptyList();
+ var incomingLinks = linksDtoCollection(linksDto(instanceId, Link.of(0, 0), Link.of(1, 1)));
+
+ when(repository.findByInstanceId(any(UUID.class))).thenReturn(existedLinks);
+ doNothing().when(repository).deleteAllInBatch(any());
+ when(repository.saveAll(any())).thenReturn(emptyList());
+
+ service.updateInstanceLinks(instanceId, incomingLinks);
+
+ var saveCaptor = linksCaptor();
+ var deleteCaptor = linksCaptor();
+ verify(repository).saveAll(saveCaptor.capture());
+ verify(repository).deleteAllInBatch(deleteCaptor.capture());
+
+ assertThat(saveCaptor.getValue()).hasSize(2)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[0], Link.TAGS[1]);
+
+ assertThat(deleteCaptor.getValue()).isEmpty();
+ }
+
+ @Test
+ void updateInstanceLinks_positive_deleteAllLinks_whenIncomingIsEmpty() {
+ var instanceId = randomUUID();
+ var existedLinks = links(instanceId, Link.of(0, 0), Link.of(1, 1));
+ var incomingLinks = linksDtoCollection(emptyList());
+
+ when(repository.findByInstanceId(any(UUID.class))).thenReturn(existedLinks);
+ doNothing().when(repository).deleteAllInBatch(any());
+ when(repository.saveAll(any())).thenReturn(emptyList());
+
+ service.updateInstanceLinks(instanceId, incomingLinks);
+
+ var saveCaptor = linksCaptor();
+ var deleteCaptor = linksCaptor();
+ verify(repository).saveAll(saveCaptor.capture());
+ verify(repository).deleteAllInBatch(deleteCaptor.capture());
+
+ assertThat(saveCaptor.getValue()).isEmpty();
+
+ assertThat(deleteCaptor.getValue()).hasSize(2)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[0], Link.TAGS[1]);
+ }
+
+ @Test
+ void updateInstanceLinks_positive_deleteAllExistedAndSaveAllIncomingLinks() {
+ var instanceId = randomUUID();
+ var existedLinks = links(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 2),
+ Link.of(3, 3)
+ );
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 1),
+ Link.of(1, 0),
+ Link.of(2, 3),
+ Link.of(3, 2)
+ ));
+
+ when(repository.findByInstanceId(instanceId)).thenReturn(existedLinks);
+ doNothing().when(repository).deleteAllInBatch(any());
+ when(repository.saveAll(any())).thenReturn(emptyList());
+
+ service.updateInstanceLinks(instanceId, incomingLinks);
+
+ var saveCaptor = linksCaptor();
+ var deleteCaptor = linksCaptor();
+ verify(repository).saveAll(saveCaptor.capture());
+ verify(repository).deleteAllInBatch(deleteCaptor.capture());
+
+ assertThat(saveCaptor.getValue()).hasSize(4)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[0], Link.TAGS[1], Link.TAGS[2], Link.TAGS[3]);
+
+ assertThat(deleteCaptor.getValue()).hasSize(4)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[0], Link.TAGS[1], Link.TAGS[2], Link.TAGS[3]);
+ }
+
+ @Test
+ void updateInstanceLinks_positive_saveOnlyNewLinks() {
+ var instanceId = randomUUID();
+ var existedLinks = links(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1)
+ );
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 2),
+ Link.of(3, 3)
+ ));
+
+ when(repository.findByInstanceId(instanceId)).thenReturn(existedLinks);
+ doNothing().when(repository).deleteAllInBatch(any());
+ when(repository.saveAll(any())).thenReturn(emptyList());
+
+ service.updateInstanceLinks(instanceId, incomingLinks);
+
+ var saveCaptor = linksCaptor();
+ var deleteCaptor = linksCaptor();
+ verify(repository).saveAll(saveCaptor.capture());
+ verify(repository).deleteAllInBatch(deleteCaptor.capture());
+
+ assertThat(saveCaptor.getValue()).hasSize(2)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[2], Link.TAGS[3]);
+
+ assertThat(deleteCaptor.getValue()).isEmpty();
+ }
+
+ @Test
+ void updateInstanceLinks_positive_deleteAndSaveLinks_whenHaveDifference() {
+ var instanceId = randomUUID();
+ var existedLinks = links(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 2),
+ Link.of(3, 3)
+ );
+ var incomingLinks = linksDtoCollection(linksDto(instanceId,
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 3),
+ Link.of(3, 2)
+ ));
+
+ when(repository.findByInstanceId(instanceId)).thenReturn(existedLinks);
+ doNothing().when(repository).deleteAllInBatch(any());
+ when(repository.saveAll(any())).thenReturn(emptyList());
+
+ service.updateInstanceLinks(instanceId, incomingLinks);
+
+ var saveCaptor = linksCaptor();
+ var deleteCaptor = linksCaptor();
+ verify(repository).saveAll(saveCaptor.capture());
+ verify(repository).deleteAllInBatch(deleteCaptor.capture());
+
+ assertThat(saveCaptor.getValue()).hasSize(2)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[2], Link.TAGS[3]);
+
+ assertThat(deleteCaptor.getValue()).hasSize(2)
+ .extracting(InstanceLink::getBibRecordTag)
+ .containsOnly(Link.TAGS[2], Link.TAGS[3]);
+ }
+
+ @Test
+ void updateInstanceLinks_negative_whenInstanceIdIsNotSameForIncomingLinks() {
+ var instanceId = randomUUID();
+ var incomingLinks = linksDtoCollection(linksDto(randomUUID(),
+ Link.of(0, 0),
+ Link.of(1, 1),
+ Link.of(2, 3),
+ Link.of(3, 2)
+ ));
+
+ var exception = Assertions.assertThrows(RequestBodyValidationException.class,
+ () -> service.updateInstanceLinks(instanceId, incomingLinks));
+
+ assertThat(exception)
+ .hasMessage("Link should have instanceId = " + instanceId)
+ .extracting(RequestBodyValidationException::getInvalidParameters)
+ .returns(4, from(List::size));
+ }
+
+ private ArgumentCaptor> linksCaptor() {
+ @SuppressWarnings("unchecked") var listClass = (Class>) (Class>) List.class;
+ return ArgumentCaptor.forClass(listClass);
+ }
+
+}
diff --git a/src/test/java/org/folio/support/TestUtils.java b/src/test/java/org/folio/support/TestUtils.java
new file mode 100644
index 00000000..5b9cfe2b
--- /dev/null
+++ b/src/test/java/org/folio/support/TestUtils.java
@@ -0,0 +1,69 @@
+package org.folio.support;
+
+import static java.util.UUID.randomUUID;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import lombok.SneakyThrows;
+import org.folio.entlinks.model.entity.InstanceLink;
+import org.folio.qm.domain.dto.InstanceLinkDto;
+import org.folio.qm.domain.dto.InstanceLinkDtoCollection;
+
+public class TestUtils {
+
+ public 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);
+
+ @SneakyThrows
+ public static String asJson(Object value) {
+ return OBJECT_MAPPER.writeValueAsString(value);
+ }
+
+ public static List linksDto(UUID instanceId, Link... links) {
+ return Arrays.stream(links).map(link -> link.toDto(instanceId)).toList();
+ }
+
+
+ public static InstanceLinkDtoCollection linksDtoCollection(List links) {
+ return new InstanceLinkDtoCollection().links(links).totalRecords(links.size());
+ }
+
+ public static List links(UUID instanceId, Link... links) {
+ return Arrays.stream(links).map(link -> link.toEntity(instanceId)).toList();
+ }
+
+ public record Link(UUID authorityId, String tag) {
+
+ public static final UUID[] AUTH_IDS = new UUID[] {randomUUID(), randomUUID(), randomUUID(), randomUUID()};
+ public static final String[] TAGS = new String[] {"100", "101", "700", "710"};
+
+ public static Link of(int authIdNum, int tagNum) {
+ return new Link(AUTH_IDS[authIdNum], TAGS[tagNum]);
+ }
+
+ public InstanceLinkDto toDto(UUID instanceId) {
+ return new InstanceLinkDto()
+ .instanceId(instanceId)
+ .authorityId(authorityId)
+ .authorityNaturalId(authorityId.toString())
+ .bibRecordSubfields(List.of("a", "b"))
+ .bibRecordTag(tag);
+ }
+
+ public InstanceLink toEntity(UUID instanceId) {
+ return InstanceLink.builder()
+ .instanceId(instanceId)
+ .authorityId(authorityId)
+ .authorityNaturalId(authorityId.toString())
+ .bibRecordTag(tag)
+ .bibRecordSubfields(List.of("a", "b"))
+ .build();
+ }
+ }
+}
diff --git a/src/test/java/org/folio/support/base/IntegrationTestBase.java b/src/test/java/org/folio/support/base/IntegrationTestBase.java
new file mode 100644
index 00000000..cc56277a
--- /dev/null
+++ b/src/test/java/org/folio/support/base/IntegrationTestBase.java
@@ -0,0 +1,83 @@
+package org.folio.support.base;
+
+import static org.folio.support.TestUtils.asJson;
+import static org.folio.support.base.TestConstants.TENANT_ID;
+import static org.folio.support.base.TestConstants.USER_ID;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import lombok.SneakyThrows;
+import org.folio.spring.integration.XOkapiHeaders;
+import org.folio.support.extension.EnablePostgres;
+import org.folio.tenant.domain.dto.TenantAttributes;
+import org.junit.jupiter.api.BeforeAll;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+@EnablePostgres
+@SpringBootTest
+@ActiveProfiles("test")
+@AutoConfigureMockMvc
+public class IntegrationTestBase {
+
+ protected static MockMvc mockMvc;
+
+ @BeforeAll
+ static void setUp(@Autowired MockMvc mockMvc) {
+ IntegrationTestBase.mockMvc = mockMvc;
+ setUpTenant();
+ }
+
+ @SneakyThrows
+ protected static void setUpTenant() {
+ doPost("/_/tenant", new TenantAttributes().moduleTo("mod-entities-links"));
+ }
+
+ protected static HttpHeaders defaultHeaders() {
+ var httpHeaders = new HttpHeaders();
+
+ httpHeaders.setContentType(APPLICATION_JSON);
+ httpHeaders.add(XOkapiHeaders.TENANT, TENANT_ID);
+ httpHeaders.add(XOkapiHeaders.USER_ID, USER_ID);
+
+ return httpHeaders;
+ }
+
+ @SneakyThrows
+ protected static ResultActions tryGet(String uri, Object... args) {
+ return mockMvc.perform(get(uri, args).headers(defaultHeaders()).accept(APPLICATION_JSON));
+ }
+
+ @SneakyThrows
+ protected static ResultActions doGet(String uri, Object... args) {
+ return tryGet(uri, args).andExpect(status().isOk());
+ }
+
+ @SneakyThrows
+ protected static ResultActions tryPut(String uri, Object body, Object... args) {
+ return mockMvc.perform(put(uri, args).content(body == null ? "" : asJson(body)).headers(defaultHeaders()));
+ }
+
+ @SneakyThrows
+ protected static ResultActions doPut(String uri, Object body, Object... args) {
+ return tryPut(uri, body, args).andExpect(status().is2xxSuccessful());
+ }
+
+ @SneakyThrows
+ protected static ResultActions tryPost(String uri, Object body, Object... args) {
+ return mockMvc.perform(post(uri, args).content(asJson(body)).headers(defaultHeaders()));
+ }
+
+ @SneakyThrows
+ protected static ResultActions doPost(String uri, Object body, Object... args) {
+ return tryPost(uri, body, args).andExpect(status().is2xxSuccessful());
+ }
+}
diff --git a/src/test/java/org/folio/support/base/TestConstants.java b/src/test/java/org/folio/support/base/TestConstants.java
new file mode 100644
index 00000000..2feb5fcf
--- /dev/null
+++ b/src/test/java/org/folio/support/base/TestConstants.java
@@ -0,0 +1,7 @@
+package org.folio.support.base;
+
+public class TestConstants {
+
+ public static final String TENANT_ID = "test";
+ public static final String USER_ID = "38d3a441-c100-5e8d-bd12-71bde492b723";
+}
diff --git a/src/test/java/org/folio/support/extension/EnablePostgres.java b/src/test/java/org/folio/support/extension/EnablePostgres.java
new file mode 100644
index 00000000..f04fa9a3
--- /dev/null
+++ b/src/test/java/org/folio/support/extension/EnablePostgres.java
@@ -0,0 +1,13 @@
+package org.folio.support.extension;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.folio.support.extension.impl.PostgresContainerExtension;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@ExtendWith(PostgresContainerExtension.class)
+public @interface EnablePostgres { }
diff --git a/src/test/java/org/folio/support/extension/impl/PostgresContainerExtension.java b/src/test/java/org/folio/support/extension/impl/PostgresContainerExtension.java
new file mode 100644
index 00000000..367f2226
--- /dev/null
+++ b/src/test/java/org/folio/support/extension/impl/PostgresContainerExtension.java
@@ -0,0 +1,45 @@
+package org.folio.support.extension.impl;
+
+import static org.testcontainers.utility.DockerImageName.parse;
+
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+public class PostgresContainerExtension implements BeforeAllCallback, AfterAllCallback {
+
+ private static final String DATABASE_URL_PROPERTY_NAME = "spring.datasource.url";
+ private static final String DATABASE_USERNAME_PROPERTY_NAME = "spring.datasource.username";
+ private static final String DATABASE_PASSWORD_PROPERTY_NAME = "spring.datasource.password";
+
+ private static final DockerImageName DOCKER_IMAGE = parse("postgres:12-alpine");
+
+ private static final String DATABASE_NAME = "folio_test";
+ private static final String DATABASE_USERNAME = "folio_admin";
+ private static final String DATABASE_PASSWORD = "password";
+
+ private static final PostgreSQLContainer> CONTAINER = new PostgreSQLContainer<>(DOCKER_IMAGE)
+ .withDatabaseName(DATABASE_NAME)
+ .withUsername(DATABASE_USERNAME)
+ .withPassword(DATABASE_PASSWORD);
+
+ @Override
+ public void beforeAll(ExtensionContext context) {
+ if (!CONTAINER.isRunning()) {
+ CONTAINER.start();
+ }
+
+ System.setProperty(DATABASE_URL_PROPERTY_NAME, CONTAINER.getJdbcUrl());
+ System.setProperty(DATABASE_USERNAME_PROPERTY_NAME, CONTAINER.getUsername());
+ System.setProperty(DATABASE_PASSWORD_PROPERTY_NAME, CONTAINER.getPassword());
+ }
+
+ @Override
+ public void afterAll(ExtensionContext context) {
+ System.clearProperty(DATABASE_URL_PROPERTY_NAME);
+ System.clearProperty(DATABASE_USERNAME_PROPERTY_NAME);
+ System.clearProperty(DATABASE_PASSWORD_PROPERTY_NAME);
+ }
+}
diff --git a/src/test/java/org/folio/support/types/IntegrationTest.java b/src/test/java/org/folio/support/types/IntegrationTest.java
new file mode 100644
index 00000000..a679ffcf
--- /dev/null
+++ b/src/test/java/org/folio/support/types/IntegrationTest.java
@@ -0,0 +1,13 @@
+package org.folio.support.types;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+
+@Tag("integration")
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface IntegrationTest {
+}
diff --git a/src/test/java/org/folio/support/types/UnitTest.java b/src/test/java/org/folio/support/types/UnitTest.java
new file mode 100644
index 00000000..e3f0667f
--- /dev/null
+++ b/src/test/java/org/folio/support/types/UnitTest.java
@@ -0,0 +1,13 @@
+package org.folio.support.types;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.Tag;
+
+@Tag("unit")
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface UnitTest {
+}
diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml
new file mode 100644
index 00000000..6df1e51e
--- /dev/null
+++ b/src/test/resources/application-test.yaml
@@ -0,0 +1,35 @@
+spring:
+ application:
+ name: mod-entity-links
+ datasource:
+ continue-on-error: true
+ password: ${DB_PASSWORD:folio_admin}
+ url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_DATABASE:okapi_modules}
+ username: ${DB_USERNAME:folio_admin}
+ liquibase:
+ change-log: classpath:db/changelog/changelog-master.xml
+ jpa:
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQL10Dialect
+ jackson:
+ default-property-inclusion: non_null
+ deserialization:
+ fail-on-unknown-properties: false
+ accept-single-value-as-array: true
+
+folio:
+ environment: ${ENV:folio}
+ tenant:
+ validation:
+ enabled: true
+ logging:
+ request:
+ enabled: true
+ feign:
+ enabled: false
+
+management.endpoints.web:
+ base-path: /admin
+ exposure.include: info,health,liquibase,threaddump,heapdump
+server.port: 8081