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