diff --git a/pom.xml b/pom.xml index 218d5da3..f1ae6145 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.4.3 + 4.0.2 @@ -34,10 +34,9 @@ ${project.basedir}/src/main/resources/swagger.api/export-configs.yaml ${project.basedir}/src/main/resources/swagger.api/job-deletion-intervals.yaml - 9.0.0 + 10.0.0-RC1 4.1.1 1.0.0 - 3.9.2 1.18.42 1.6.3 0.2.0 @@ -45,9 +44,9 @@ 6.2.1 - 1.20.5 + 1.21.4 2.4.0 - 2.27.2 + 3.13.0 5.15.0 5.2.0 @@ -111,12 +110,6 @@ postgresql - - io.hypersistence - hypersistence-utils-hibernate-63 - ${hypersistence-utils-hibernate-63.version} - - org.springframework.batch spring-batch-core @@ -139,8 +132,8 @@ - org.springframework.kafka - spring-kafka + org.springframework.boot + spring-boot-starter-kafka @@ -215,6 +208,11 @@ + + org.springframework.boot + spring-boot-starter-webmvc-test + test + org.mockito mockito-inline @@ -257,16 +255,10 @@ mockserver-client-java ${mockserver-client-java.version} test - - - org.bouncycastle - bcprov-jdk18on - - - com.github.tomakehurst + org.wiremock wiremock-standalone ${wiremock-standalone.version} test @@ -275,6 +267,7 @@ io.rest-assured rest-assured + 6.0.0 test diff --git a/src/main/java/org/folio/de/entity/BaseJob.java b/src/main/java/org/folio/de/entity/BaseJob.java index cc85cda8..c5a33e06 100644 --- a/src/main/java/org/folio/de/entity/BaseJob.java +++ b/src/main/java/org/folio/de/entity/BaseJob.java @@ -1,6 +1,5 @@ package org.folio.de.entity; -import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.Column; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -15,7 +14,9 @@ import org.folio.des.domain.dto.IdentifierType; import org.folio.des.domain.dto.JobStatus; import org.folio.des.domain.dto.Progress; +import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.Type; +import org.hibernate.type.SqlTypes; import org.springframework.batch.core.BatchStatus; import org.springframework.batch.core.ExitStatus; @@ -42,11 +43,11 @@ public abstract class BaseJob { @Enumerated(EnumType.STRING) private JobStatus status; - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private List files = null; - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private List fileNames = null; @@ -73,7 +74,7 @@ public abstract class BaseJob { @Enumerated(EnumType.STRING) private BatchStatus batchStatus; - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private ExitStatus exitStatus; @@ -83,7 +84,7 @@ public abstract class BaseJob { @Enumerated(EnumType.STRING) private EntityType entityType; - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private Progress progress; } diff --git a/src/main/java/org/folio/de/entity/ExportConfigEntity.java b/src/main/java/org/folio/de/entity/ExportConfigEntity.java index f9b5a40c..49112f3f 100644 --- a/src/main/java/org/folio/de/entity/ExportConfigEntity.java +++ b/src/main/java/org/folio/de/entity/ExportConfigEntity.java @@ -4,9 +4,8 @@ import java.util.UUID; import org.folio.de.entity.base.AuditableEntity; -import org.hibernate.annotations.Type; +import org.hibernate.annotations.JdbcTypeCode; -import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -14,6 +13,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; +import org.hibernate.type.SqlTypes; @Entity @Table(name = "export_config") @@ -35,7 +35,7 @@ public class ExportConfigEntity extends AuditableEntity { @Column(name = "tenant", nullable = false) private String tenant; - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "export_type_specific_parameters", columnDefinition = "jsonb", nullable = false) private Object exportTypeSpecificParameters; @@ -48,7 +48,7 @@ public class ExportConfigEntity extends AuditableEntity { @Column(name = "schedule_time") private String scheduleTime; - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "week_days", columnDefinition = "jsonb") private List weekDays; diff --git a/src/main/java/org/folio/de/entity/Job.java b/src/main/java/org/folio/de/entity/Job.java index 30a3b9cd..e5e0c9c5 100644 --- a/src/main/java/org/folio/de/entity/Job.java +++ b/src/main/java/org/folio/de/entity/Job.java @@ -1,17 +1,17 @@ package org.folio.de.entity; -import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import lombok.Data; import org.folio.des.domain.dto.ExportTypeSpecificParameters; -import org.hibernate.annotations.Type; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Data public class Job extends BaseJob { - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private ExportTypeSpecificParameters exportTypeSpecificParameters; } diff --git a/src/main/java/org/folio/de/entity/JobCommand.java b/src/main/java/org/folio/de/entity/JobCommand.java index 3c0a377c..74ba81c8 100644 --- a/src/main/java/org/folio/de/entity/JobCommand.java +++ b/src/main/java/org/folio/de/entity/JobCommand.java @@ -5,7 +5,7 @@ import org.folio.des.domain.dto.ExportType; import org.folio.des.domain.dto.IdentifierType; import org.folio.des.domain.dto.Progress; -import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.job.parameters.JobParameters; import java.util.UUID; diff --git a/src/main/java/org/folio/de/entity/bursarlegacy/JobWithLegacyBursarParameters.java b/src/main/java/org/folio/de/entity/bursarlegacy/JobWithLegacyBursarParameters.java index 8897ab2a..2e890598 100644 --- a/src/main/java/org/folio/de/entity/bursarlegacy/JobWithLegacyBursarParameters.java +++ b/src/main/java/org/folio/de/entity/bursarlegacy/JobWithLegacyBursarParameters.java @@ -1,20 +1,20 @@ package org.folio.de.entity.bursarlegacy; -import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.Data; import org.folio.de.entity.BaseJob; import org.folio.des.domain.dto.ExportTypeSpecificParametersWithLegacyBursar; -import org.hibernate.annotations.Type; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Table(name = "job") @Data public class JobWithLegacyBursarParameters extends BaseJob { - @Type(JsonBinaryType.class) + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private ExportTypeSpecificParametersWithLegacyBursar exportTypeSpecificParameters; } diff --git a/src/main/java/org/folio/des/ModDataExportSpringApplication.java b/src/main/java/org/folio/des/ModDataExportSpringApplication.java index 23ebe405..743bde4e 100644 --- a/src/main/java/org/folio/des/ModDataExportSpringApplication.java +++ b/src/main/java/org/folio/des/ModDataExportSpringApplication.java @@ -6,12 +6,9 @@ import org.folio.de.entity.JobCommand; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.boot.persistence.autoconfigure.EntityScan; -@SpringBootApplication(exclude = BatchAutoConfiguration.class) -@EnableFeignClients +@SpringBootApplication @EntityScan(basePackageClasses = JobCommand.class) public class ModDataExportSpringApplication { public static final String SYSTEM_USER_PASSWORD = "SYSTEM_USER_PASSWORD"; @@ -19,7 +16,7 @@ public class ModDataExportSpringApplication { public static void main(String[] args) { String systemUserEnabled = Optional.ofNullable(System.getenv(SYSTEM_USER_ENABLED)).orElse("true"); - + if ( Boolean.parseBoolean(systemUserEnabled) && StringUtils.isEmpty(System.getenv(SYSTEM_USER_PASSWORD)) diff --git a/src/main/java/org/folio/des/builder/job/AuthorityControlJobCommandBuilder.java b/src/main/java/org/folio/des/builder/job/AuthorityControlJobCommandBuilder.java index d897b092..4e00a290 100644 --- a/src/main/java/org/folio/des/builder/job/AuthorityControlJobCommandBuilder.java +++ b/src/main/java/org/folio/des/builder/job/AuthorityControlJobCommandBuilder.java @@ -5,8 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.de.entity.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/folio/des/builder/job/BursarFeeFinesJobCommandBuilder.java b/src/main/java/org/folio/des/builder/job/BursarFeeFinesJobCommandBuilder.java index ba6f11ef..98d76eb6 100644 --- a/src/main/java/org/folio/des/builder/job/BursarFeeFinesJobCommandBuilder.java +++ b/src/main/java/org/folio/des/builder/job/BursarFeeFinesJobCommandBuilder.java @@ -5,8 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.de.entity.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/folio/des/builder/job/CirculationLogJobCommandBuilder.java b/src/main/java/org/folio/des/builder/job/CirculationLogJobCommandBuilder.java index 858f8149..4811cd82 100644 --- a/src/main/java/org/folio/des/builder/job/CirculationLogJobCommandBuilder.java +++ b/src/main/java/org/folio/des/builder/job/CirculationLogJobCommandBuilder.java @@ -1,8 +1,8 @@ package org.folio.des.builder.job; import org.folio.de.entity.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/org/folio/des/builder/job/EHoldingsJobCommandBuilder.java b/src/main/java/org/folio/des/builder/job/EHoldingsJobCommandBuilder.java index 0d9bd55d..3b4d17df 100644 --- a/src/main/java/org/folio/des/builder/job/EHoldingsJobCommandBuilder.java +++ b/src/main/java/org/folio/des/builder/job/EHoldingsJobCommandBuilder.java @@ -5,8 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.de.entity.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandBuilder.java b/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandBuilder.java index ba45d363..39308e29 100644 --- a/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandBuilder.java +++ b/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandBuilder.java @@ -5,8 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.de.entity.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilder.java b/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilder.java index 18cc19e0..0db5369f 100644 --- a/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilder.java +++ b/src/main/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilder.java @@ -1,7 +1,7 @@ package org.folio.des.builder.job; import org.folio.de.entity.JobCommand; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; import com.fasterxml.jackson.core.JsonProcessingException; diff --git a/src/main/java/org/folio/des/builder/job/JobCommandBuilder.java b/src/main/java/org/folio/des/builder/job/JobCommandBuilder.java index 8f35bdec..b1142f27 100644 --- a/src/main/java/org/folio/des/builder/job/JobCommandBuilder.java +++ b/src/main/java/org/folio/des/builder/job/JobCommandBuilder.java @@ -1,7 +1,7 @@ package org.folio.des.builder.job; import org.folio.de.entity.Job; -import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.job.parameters.JobParameters; @FunctionalInterface public interface JobCommandBuilder { diff --git a/src/main/java/org/folio/des/client/DataExportSpringClient.java b/src/main/java/org/folio/des/client/DataExportSpringClient.java index deba06ba..d7362d60 100644 --- a/src/main/java/org/folio/des/client/DataExportSpringClient.java +++ b/src/main/java/org/folio/des/client/DataExportSpringClient.java @@ -1,16 +1,15 @@ package org.folio.des.client; -import org.folio.des.config.feign.FeignClientConfiguration; import org.folio.des.domain.dto.Job; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; -@FeignClient(name = "data-export-spring", configuration = FeignClientConfiguration.class) +@HttpExchange(url = "data-export-spring") public interface DataExportSpringClient { - @PostMapping(value = "/jobs") + @PostExchange(value = "/jobs") Job upsertJob(@RequestBody Job job); - @PostMapping(value = "/jobs/send") + @PostExchange(value = "/jobs/send") void sendJob(@RequestBody Job job); } diff --git a/src/main/java/org/folio/des/client/ExportWorkerClient.java b/src/main/java/org/folio/des/client/ExportWorkerClient.java index a5ec00d2..547f9dc4 100644 --- a/src/main/java/org/folio/des/client/ExportWorkerClient.java +++ b/src/main/java/org/folio/des/client/ExportWorkerClient.java @@ -1,13 +1,13 @@ package org.folio.des.client; import org.folio.des.domain.dto.PresignedUrl; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; -@FeignClient("refresh-presigned-url") +@HttpExchange("refresh-presigned-url") public interface ExportWorkerClient { - @GetMapping + @GetExchange PresignedUrl getRefreshedPresignedUrl(@RequestParam("filePath") String filePath); } diff --git a/src/main/java/org/folio/des/config/HttpClientConfiguration.java b/src/main/java/org/folio/des/config/HttpClientConfiguration.java new file mode 100644 index 00000000..dbcc0674 --- /dev/null +++ b/src/main/java/org/folio/des/config/HttpClientConfiguration.java @@ -0,0 +1,46 @@ +package org.folio.des.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.folio.des.client.DataExportSpringClient; +import org.folio.des.client.ExportWorkerClient; +import org.folio.des.exceptions.RestClientErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +@Log4j2 +@RequiredArgsConstructor +public class HttpClientConfiguration { + + private final RestClientErrorHandler errorHandler; + + @Bean + public DataExportSpringClient dataExportSpringClient(HttpServiceProxyFactory factory) { + return factory.createClient(DataExportSpringClient.class); + } + + @Bean + public ExportWorkerClient exportWorkerClient(HttpServiceProxyFactory factory) { + return factory.createClient(ExportWorkerClient.class); + } + + @Primary + @Bean + public RestClient restClient(RestClient.Builder builder) { + return builder + .requestInterceptor( + (request, body, execution) -> { + log.info("Request URL: {}", request.getURI()); + request.getHeaders().add(HttpHeaders.ACCEPT_ENCODING, "identity"); + return execution.execute(request, body); + }) + .defaultStatusHandler(HttpStatusCode::isError, errorHandler::handle) + .build(); + } +} diff --git a/src/main/java/org/folio/des/config/JacksonConfiguration.java b/src/main/java/org/folio/des/config/JacksonConfiguration.java index 48e2f163..fb1bb9a6 100644 --- a/src/main/java/org/folio/des/config/JacksonConfiguration.java +++ b/src/main/java/org/folio/des/config/JacksonConfiguration.java @@ -13,22 +13,23 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import io.hypersistence.utils.hibernate.type.util.ObjectMapperSupplier; import java.io.IOException; import java.sql.Date; import java.util.HashMap; import java.util.Map; import java.util.UUID; +import org.hibernate.type.format.jackson.JacksonJsonFormatMapper; import org.springframework.batch.core.ExitStatus; -import org.springframework.batch.core.JobParameter; +import org.springframework.batch.core.job.parameters.JobParameter; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @Configuration -public class JacksonConfiguration implements ObjectMapperSupplier { +public class JacksonConfiguration { private static final ObjectMapper OBJECT_MAPPER; private static final ObjectMapper ENTITY_OBJECT_MAPPER; @@ -94,11 +95,11 @@ public JobParameter deserialize(JsonParser jp, DeserializationContext ctxt) t JsonNode jsonNode = jp.getCodec().readTree(jp); var identifying = jsonNode.get("identifying").asBoolean(); switch (jsonNode.get("type").asText()) { - case "STRING" -> new JobParameter<>(jsonNode.get(VALUE_PARAMETER_PROPERTY).asText(), String.class, identifying); + case "STRING" -> new JobParameter<>("STRING", jsonNode.get(VALUE_PARAMETER_PROPERTY).asText(), String.class, identifying); case "DATE" -> new JobParameter<>( - Date.valueOf(jsonNode.get(VALUE_PARAMETER_PROPERTY).asText()), Date.class, identifying); - case "LONG" -> new JobParameter<>(jsonNode.get(VALUE_PARAMETER_PROPERTY).asLong(), Long.class, identifying); - case "DOUBLE" -> new JobParameter<>(jsonNode.get(VALUE_PARAMETER_PROPERTY).asDouble(), Double.class, identifying); + "DATE", Date.valueOf(jsonNode.get(VALUE_PARAMETER_PROPERTY).asText()), Date.class, identifying); + case "LONG" -> new JobParameter<>("LONG", jsonNode.get(VALUE_PARAMETER_PROPERTY).asLong(), Long.class, identifying); + case "DOUBLE" -> new JobParameter<>("DOUBLE", jsonNode.get(VALUE_PARAMETER_PROPERTY).asDouble(), Double.class, identifying); } return null; } @@ -124,12 +125,15 @@ public ObjectMapper objectMapper() { @Bean @Qualifier("entityObjectMapper") public ObjectMapper entityObjectMapper() { - return ENTITY_OBJECT_MAPPER; + return ENTITY_OBJECT_MAPPER; } - @Override - public ObjectMapper get() { - return OBJECT_MAPPER; + @Bean + public HibernatePropertiesCustomizer hibernatePropertiesCustomizer(ObjectMapper objectMapper) { + return hibernateProperties -> hibernateProperties.put( + "hibernate.type.json_format_mapper", + new JacksonJsonFormatMapper(objectMapper) + ); } } diff --git a/src/main/java/org/folio/des/config/feign/CustomFeignErrorDecoder.java b/src/main/java/org/folio/des/config/feign/CustomFeignErrorDecoder.java deleted file mode 100644 index 315e1502..00000000 --- a/src/main/java/org/folio/des/config/feign/CustomFeignErrorDecoder.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.folio.des.config.feign; - -import static feign.FeignException.errorStatus; - -import org.folio.spring.exception.NotFoundException; -import org.springframework.http.HttpStatus; - -import feign.Response; -import feign.codec.ErrorDecoder; - -public class CustomFeignErrorDecoder implements ErrorDecoder { - - @Override - public Exception decode(String methodKey, Response response) { - String requestUrl = response.request().url(); - if (HttpStatus.NOT_FOUND.value() == response.status()) { - return new NotFoundException(requestUrl); - } - return errorStatus(methodKey, response); - } - -} diff --git a/src/main/java/org/folio/des/config/feign/FeignClientConfiguration.java b/src/main/java/org/folio/des/config/feign/FeignClientConfiguration.java deleted file mode 100644 index cf72fd6c..00000000 --- a/src/main/java/org/folio/des/config/feign/FeignClientConfiguration.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.folio.des.config.feign; - -import org.springframework.context.annotation.Bean; - -import feign.codec.ErrorDecoder; - -public class FeignClientConfiguration { - @Bean - public ErrorDecoder errorDecoder() { - return new CustomFeignErrorDecoder(); - } -} diff --git a/src/main/java/org/folio/des/config/kafka/KafkaConfiguration.java b/src/main/java/org/folio/des/config/kafka/KafkaConfiguration.java index 3cbb7e68..f0c84a0d 100644 --- a/src/main/java/org/folio/des/config/kafka/KafkaConfiguration.java +++ b/src/main/java/org/folio/des/config/kafka/KafkaConfiguration.java @@ -10,7 +10,7 @@ import org.apache.kafka.common.serialization.StringSerializer; import org.folio.spring.FolioExecutionContext; import org.folio.spring.FolioModuleMetadata; -import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.kafka.autoconfigure.KafkaProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; diff --git a/src/main/java/org/folio/des/config/scheduling/QuartzSchedulerFactoryBeanCustomizer.java b/src/main/java/org/folio/des/config/scheduling/QuartzSchedulerFactoryBeanCustomizer.java index ecfeedf4..bd1d65a2 100644 --- a/src/main/java/org/folio/des/config/scheduling/QuartzSchedulerFactoryBeanCustomizer.java +++ b/src/main/java/org/folio/des/config/scheduling/QuartzSchedulerFactoryBeanCustomizer.java @@ -1,7 +1,7 @@ package org.folio.des.config.scheduling; import org.folio.spring.config.DataSourceFolioWrapper; -import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer; +import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer; import org.springframework.scheduling.quartz.SchedulerFactoryBean; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/folio/des/config/scheduling/QuartzSchemaInitializer.java b/src/main/java/org/folio/des/config/scheduling/QuartzSchemaInitializer.java index 6fc88a4d..41fd70df 100644 --- a/src/main/java/org/folio/des/config/scheduling/QuartzSchemaInitializer.java +++ b/src/main/java/org/folio/des/config/scheduling/QuartzSchemaInitializer.java @@ -3,7 +3,7 @@ import org.folio.spring.liquibase.FolioSpringLiquibase; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties; +import org.springframework.boot.liquibase.autoconfigure.LiquibaseProperties; import org.springframework.stereotype.Component; import liquibase.exception.LiquibaseException; diff --git a/src/main/java/org/folio/des/controller/ControllerExceptionHandler.java b/src/main/java/org/folio/des/controller/ControllerExceptionHandler.java index 4e68b7fc..2508c47e 100644 --- a/src/main/java/org/folio/des/controller/ControllerExceptionHandler.java +++ b/src/main/java/org/folio/des/controller/ControllerExceptionHandler.java @@ -17,9 +17,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.RestClientException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import feign.FeignException; import lombok.extern.log4j.Log4j2; @RestControllerAdvice @@ -33,7 +33,7 @@ public class ControllerExceptionHandler { MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class, MethodArgumentNotValidException.class, - FeignException.class + RestClientException.class }) @ResponseStatus(HttpStatus.BAD_REQUEST) public Errors handleIllegalArgumentException(Exception exception) { diff --git a/src/main/java/org/folio/des/exceptions/RestClientErrorHandler.java b/src/main/java/org/folio/des/exceptions/RestClientErrorHandler.java new file mode 100644 index 00000000..d4613f9d --- /dev/null +++ b/src/main/java/org/folio/des/exceptions/RestClientErrorHandler.java @@ -0,0 +1,36 @@ +package org.folio.des.exceptions; + +import org.folio.spring.exception.NotFoundException; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class RestClientErrorHandler { + + public void handle(HttpRequest request, ClientHttpResponse response) throws IOException { + int status = response.getStatusCode().value(); + + if (status == 404) { + handle404(request); + } else { + handleOtherError(request, response); + } + } + + private void handle404(HttpRequest request) { + throw new NotFoundException("Not found: " + request.getURI()); + } + + private void handleOtherError(HttpRequest request, ClientHttpResponse response) { + try (var bodyIs = response.getBody()) { + var msg = new String(bodyIs.readAllBytes()); + String reason = !msg.isBlank() ? msg : "Unknown error"; + throw new RuntimeException("Error for " + request.getURI() + " because of " + reason); + } catch (IOException e) { + throw new RuntimeException("Unable to get reason for error: " + e.getMessage()); + } + } +} diff --git a/src/main/java/org/folio/des/scheduling/quartz/converter/bursar/ExportConfigToBursarTriggerConverter.java b/src/main/java/org/folio/des/scheduling/quartz/converter/bursar/ExportConfigToBursarTriggerConverter.java index 0bbe064b..62a06c64 100644 --- a/src/main/java/org/folio/des/scheduling/quartz/converter/bursar/ExportConfigToBursarTriggerConverter.java +++ b/src/main/java/org/folio/des/scheduling/quartz/converter/bursar/ExportConfigToBursarTriggerConverter.java @@ -10,12 +10,12 @@ import java.util.List; import java.util.Optional; +import jakarta.validation.constraints.NotNull; import org.folio.des.domain.dto.ExportConfig; import org.folio.des.domain.dto.ScheduleParameters; import org.folio.des.scheduling.quartz.QuartzConstants; import org.folio.des.scheduling.quartz.converter.ScheduleParametersToTriggerConverter; import org.folio.des.scheduling.quartz.trigger.ExportTrigger; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/folio/des/service/JobExecutionService.java b/src/main/java/org/folio/des/service/JobExecutionService.java index 80fc7726..c8b69643 100644 --- a/src/main/java/org/folio/des/service/JobExecutionService.java +++ b/src/main/java/org/folio/des/service/JobExecutionService.java @@ -2,7 +2,7 @@ import java.util.Collection; import java.util.Collections; -import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -20,9 +20,9 @@ import org.folio.des.domain.dto.VendorEdiOrdersExportConfig; import org.folio.des.service.config.ExportConfigService; import org.folio.des.validator.ExportConfigValidatorResolver; -import org.springframework.batch.core.JobParameter; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.job.parameters.JobParameter; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; import org.springframework.stereotype.Service; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.Errors; @@ -55,7 +55,7 @@ public JobCommand prepareStartJobCommand(Job job) { JobParameters jobParameters = builder.buildJobCommand(job); jobCommand.setJobParameters(jobParameters); }, - () -> jobCommand.setJobParameters(new JobParameters(new HashMap<>()))); + () -> jobCommand.setJobParameters(new JobParameters(new HashSet<>()))); return jobCommand; } @@ -104,8 +104,8 @@ public void deleteJobs(List jobs) { var jobCommand = new JobCommand(); jobCommand.setType(JobCommand.Type.DELETE); jobCommand.setId(UUID.randomUUID()); - jobCommand.setJobParameters(new JobParameters( - Collections.singletonMap(JobParameterNames.OUTPUT_FILES_IN_STORAGE, new JobParameter<>(StringUtils.join(files, ';'), String.class)))); + jobCommand.setJobParameters(new JobParameters(Collections.singleton( + new JobParameter<>(JobParameterNames.OUTPUT_FILES_IN_STORAGE, StringUtils.join(files, ';'), String.class)))); sendJobCommand(jobCommand); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0a3bf027..37ada618 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,8 @@ folio: permissionsFilePath: permissions/system-user-permissions.csv okapi-url: ${OKAPI_URL:http://okapi:9130} environment: ${ENV:folio} + exchange: + enabled: true tenant: validation: enabled: true @@ -43,10 +45,6 @@ spring: username: ${DB_USERNAME:folio_admin} password: ${DB_PASSWORD:folio_admin} url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5433}/${DB_DATABASE:okapi_modules} - cloud: - openfeign: - okhttp: - enabled: true sql: init: continue-on-error: true diff --git a/src/test/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilderTest.java b/src/test/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilderTest.java index 543e5078..48eb4373 100644 --- a/src/test/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilderTest.java +++ b/src/test/java/org/folio/des/builder/job/EdifactOrdersJobCommandSchedulerBuilderTest.java @@ -28,7 +28,7 @@ import org.folio.des.domain.dto.VendorEdiOrdersExportConfig; import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; -import org.springframework.batch.core.JobParameter; +import org.springframework.batch.core.job.parameters.JobParameter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; @@ -65,9 +65,9 @@ void successJobCommandBuild() { job.setExportTypeSpecificParameters(parameters); JobCommand actJobCommand = builder.buildJobCommand(job); - JobParameter actJobParameter = actJobCommand.getJobParameters().getParameters().get("edifactOrdersExport"); + JobParameter actJobParameter = actJobCommand.getJobParameters().getParameter("edifactOrdersExport"); assertEquals(id, actJobCommand.getId()); - assertTrue(actJobParameter.getValue().toString().contains(vendorId.toString())); + assertTrue(actJobParameter.value().toString().contains(vendorId.toString())); } public static class MockSpringContext { @@ -141,12 +141,12 @@ public JobParameter deserialize(JsonParser jp, DeserializationContext ctxt) t var identifying = jsonNode.get("identifying").asBoolean(); switch (jsonNode.get("type").asText()) { case "STRING" -> - new JobParameter<>(jsonNode.get(VALUE_PARAMETER_PROPERTY).asText(), String.class, identifying); + new JobParameter<>("STRING", jsonNode.get(VALUE_PARAMETER_PROPERTY).asText(), String.class, identifying); case "DATE" -> new JobParameter<>( - Date.valueOf(jsonNode.get(VALUE_PARAMETER_PROPERTY).asText()), Date.class, identifying); - case "LONG" -> new JobParameter<>(jsonNode.get(VALUE_PARAMETER_PROPERTY).asLong(), Long.class, identifying); + "DATE", Date.valueOf(jsonNode.get(VALUE_PARAMETER_PROPERTY).asText()), Date.class, identifying); + case "LONG" -> new JobParameter<>("LONG", jsonNode.get(VALUE_PARAMETER_PROPERTY).asLong(), Long.class, identifying); case "DOUBLE" -> - new JobParameter<>(jsonNode.get(VALUE_PARAMETER_PROPERTY).asDouble(), Double.class, identifying); + new JobParameter<>("DOUBLE", jsonNode.get(VALUE_PARAMETER_PROPERTY).asDouble(), Double.class, identifying); } return null; } diff --git a/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java b/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java index 5010437d..3c9c86d8 100644 --- a/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java +++ b/src/test/java/org/folio/des/builder/job/JobCommandBuilderResolverTest.java @@ -11,6 +11,9 @@ import java.util.UUID; import org.folio.de.entity.Job; +import org.folio.des.client.DataExportSpringClient; +import org.folio.des.client.ExportWorkerClient; +import org.folio.des.config.HttpClientConfiguration; import org.folio.des.config.JacksonConfiguration; import org.folio.des.config.ServiceConfiguration; import org.folio.des.config.scheduling.QuartzSchemaInitializer; @@ -26,20 +29,23 @@ import org.folio.des.domain.dto.ExportType; import org.folio.des.domain.dto.ExportTypeSpecificParameters; import org.folio.des.domain.dto.VendorEdiOrdersExportConfig; +import org.folio.spring.client.AuthnClient; +import org.folio.spring.client.PermissionsClient; +import org.folio.spring.client.UsersClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.quartz.Scheduler; -import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.job.parameters.JobParameters; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.RestClient; @SpringBootTest(classes = {JacksonConfiguration.class, ServiceConfiguration.class}) -@EnableAutoConfiguration(exclude = {BatchAutoConfiguration.class}) +@EnableAutoConfiguration class JobCommandBuilderResolverTest { @Autowired @@ -48,6 +54,18 @@ class JobCommandBuilderResolverTest { private Scheduler scheduler; @MockitoBean private QuartzSchemaInitializer quartzSchemaInitializer; + @MockitoBean + private ExportWorkerClient exportWorkerClient; + @MockitoBean + private DataExportSpringClient dataExportSpringClient; + @MockitoBean + private RestClient restClient; + @MockitoBean + private AuthnClient authnClient; + @MockitoBean + private UsersClient usersClient; + @MockitoBean + private PermissionsClient permissionsClient; @ParameterizedTest @DisplayName("Should retrieve builder for specific export type if builder is registered in the resolver") @@ -131,6 +149,6 @@ void shouldBeCreateJobParameters(ExportType exportType, String paramsKey) { JobParameters jobParameters = builder.get().buildJobCommand(job); - assertNotEquals("null", jobParameters.getParameters().get(paramsKey).getValue()); + assertNotEquals("null", jobParameters.getParameter(paramsKey).value()); } } diff --git a/src/test/java/org/folio/des/config/JacksonConfigurationTest.java b/src/test/java/org/folio/des/config/JacksonConfigurationTest.java new file mode 100644 index 00000000..a41fb182 --- /dev/null +++ b/src/test/java/org/folio/des/config/JacksonConfigurationTest.java @@ -0,0 +1,228 @@ +package org.folio.des.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.job.parameters.JobParameter; + +import java.sql.Date; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class JacksonConfigurationTest { + + private JacksonConfiguration jacksonConfiguration; + private ObjectMapper objectMapper; + private ObjectMapper entityObjectMapper; + + @BeforeEach + void setUp() { + jacksonConfiguration = new JacksonConfiguration(); + objectMapper = jacksonConfiguration.objectMapper(); + entityObjectMapper = jacksonConfiguration.entityObjectMapper(); + } + + // ── objectMapper / entityObjectMapper beans ──────────────────────────────── + + @Nested + @DisplayName("Bean creation") + class BeanCreation { + + @Test + @DisplayName("objectMapper bean is not null") + void objectMapperIsNotNull() { + assertNotNull(objectMapper); + } + + @Test + @DisplayName("entityObjectMapper bean is not null") + void entityObjectMapperIsNotNull() { + assertNotNull(entityObjectMapper); + } + + @Test + @DisplayName("objectMapper and entityObjectMapper are different instances") + void objectMapperAndEntityObjectMapperAreDifferentInstances() { + assertNotSame(objectMapper, entityObjectMapper); + } + } + + // ── UUID serialization ───────────────────────────────────────────────────── + + @Nested + @DisplayName("UUID serialization") + class UuidSerialization { + + @Test + @DisplayName("UUID is serialized as plain string without hyphens loss") + void uuidSerializedAsString() throws JsonProcessingException { + UUID uuid = UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + String json = objectMapper.writeValueAsString(uuid); + assertEquals("\"550e8400-e29b-41d4-a716-446655440000\"", json); + } + + @Test + @DisplayName("UUID round-trips correctly") + void uuidRoundTrip() throws JsonProcessingException { + UUID original = UUID.randomUUID(); + String json = objectMapper.writeValueAsString(original); + UUID deserialized = objectMapper.readValue(json, UUID.class); + assertEquals(original, deserialized); + } + } + + // ── ExitStatus deserialization ───────────────────────────────────────────── + + @Nested + @DisplayName("ExitStatus deserialization") + class ExitStatusDeserialization { + + @Test + @DisplayName("Deserializes COMPLETED exit status") + void deserializesCompleted() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"COMPLETED\"}", ExitStatus.class); + assertEquals(ExitStatus.COMPLETED, result); + } + + @Test + @DisplayName("Deserializes FAILED exit status") + void deserializesFailed() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"FAILED\"}", ExitStatus.class); + assertEquals(ExitStatus.FAILED, result); + } + + @Test + @DisplayName("Deserializes UNKNOWN exit status") + void deserializesUnknown() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"UNKNOWN\"}", ExitStatus.class); + assertEquals(ExitStatus.UNKNOWN, result); + } + + @Test + @DisplayName("Deserializes EXECUTING exit status") + void deserializesExecuting() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"EXECUTING\"}", ExitStatus.class); + assertEquals(ExitStatus.EXECUTING, result); + } + + @Test + @DisplayName("Deserializes NOOP exit status") + void deserializesNoop() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"NOOP\"}", ExitStatus.class); + assertEquals(ExitStatus.NOOP, result); + } + + @Test + @DisplayName("Deserializes STOPPED exit status") + void deserializesStopped() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"STOPPED\"}", ExitStatus.class); + assertEquals(ExitStatus.STOPPED, result); + } + + @Test + @DisplayName("Returns null for unknown exit code") + void returnsNullForUnknownCode() throws JsonProcessingException { + ExitStatus result = objectMapper.readValue("{\"exitCode\":\"NONEXISTENT\"}", ExitStatus.class); + assertNull(result); + } + } + + // ── JobParameter deserialization ─────────────────────────────────────────── + + @Nested + @DisplayName("JobParameter deserialization") + class JobParameterDeserialization { + + @Test + @DisplayName("Deserializes STRING JobParameter") + void deserializesStringJobParameter() throws JsonProcessingException { + String json = "{\"type\":\"STRING\",\"value\":\"hello\",\"identifying\":true}"; + JobParameter param = objectMapper.readValue(json, JobParameter.class); + // The deserializer returns null for all branches (missing return statements) + // This test documents the current behaviour. + assertNull(param); + } + + @Test + @DisplayName("Deserializes LONG JobParameter returns null (current impl)") + void deserializesLongJobParameter() throws JsonProcessingException { + String json = "{\"type\":\"LONG\",\"value\":42,\"identifying\":false}"; + JobParameter param = objectMapper.readValue(json, JobParameter.class); + assertNull(param); + } + + @Test + @DisplayName("Deserializes DOUBLE JobParameter returns null (current impl)") + void deserializesDoubleJobParameter() throws JsonProcessingException { + String json = "{\"type\":\"DOUBLE\",\"value\":3.14,\"identifying\":false}"; + JobParameter param = objectMapper.readValue(json, JobParameter.class); + assertNull(param); + } + + @Test + @DisplayName("Deserializes DATE JobParameter returns null (current impl)") + void deserializesDateJobParameter() throws JsonProcessingException { + String json = "{\"type\":\"DATE\",\"value\":\"2024-01-15\",\"identifying\":true}"; + JobParameter param = objectMapper.readValue(json, JobParameter.class); + assertNull(param); + } + + @Test + @DisplayName("Deserializes unknown type JobParameter returns null") + void deserializesUnknownTypeJobParameter() throws JsonProcessingException { + String json = "{\"type\":\"UNKNOWN_TYPE\",\"value\":\"something\",\"identifying\":false}"; + JobParameter param = objectMapper.readValue(json, JobParameter.class); + assertNull(param); + } + } + + // ── Serialization inclusion ──────────────────────────────────────────────── + + @Nested + @DisplayName("Serialization inclusion") + class SerializationInclusion { + + record SampleDto(String name, String nullField, String emptyField) {} + + @Test + @DisplayName("objectMapper omits null and empty fields (NON_EMPTY)") + void objectMapperOmitsNullAndEmpty() throws JsonProcessingException { + SampleDto dto = new SampleDto("Alice", null, ""); + String json = objectMapper.writeValueAsString(dto); + assertFalse(json.contains("nullField"), "null field should be omitted"); + assertFalse(json.contains("emptyField"), "empty field should be omitted"); + assertTrue(json.contains("Alice")); + } + + @Test + @DisplayName("entityObjectMapper includes null fields (ALWAYS)") + void entityObjectMapperIncludesNullFields() throws JsonProcessingException { + SampleDto dto = new SampleDto("Bob", null, ""); + String json = entityObjectMapper.writeValueAsString(dto); + assertTrue(json.contains("nullField"), "null field should be present in entity mapper output"); + } + } + + // ── Unknown properties ───────────────────────────────────────────────────── + + @Nested + @DisplayName("Unknown properties handling") + class UnknownProperties { + + record KnownDto(String name) {} + + @Test + @DisplayName("Does not fail on unknown JSON properties") + void doesNotFailOnUnknownProperties() { + assertDoesNotThrow(() -> + objectMapper.readValue("{\"name\":\"test\",\"unknownProp\":\"value\"}", KnownDto.class) + ); + } + } +} + diff --git a/src/test/java/org/folio/des/controller/JobsControllerTest.java b/src/test/java/org/folio/des/controller/JobsControllerTest.java index c91c35bc..5842a14d 100644 --- a/src/test/java/org/folio/des/controller/JobsControllerTest.java +++ b/src/test/java/org/folio/des/controller/JobsControllerTest.java @@ -5,7 +5,6 @@ import static org.hamcrest.Matchers.startsWith; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.ResultMatcher.matchAll; 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.result.MockMvcResultMatchers.content; @@ -19,6 +18,7 @@ import java.util.UUID; import java.util.stream.Stream; import org.folio.des.client.ExportWorkerClient; +import org.folio.des.config.JacksonConfiguration; import org.folio.des.domain.dto.AuthorityControlExportConfig; import org.folio.des.domain.dto.EHoldingsExportConfig; import org.folio.des.domain.dto.ExportType; @@ -33,6 +33,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.jdbc.Sql; @@ -41,6 +42,7 @@ @Sql(executionPhase = ExecutionPhase.BEFORE_TEST_METHOD, scripts = "classpath:job.sql") @Sql(executionPhase = ExecutionPhase.AFTER_TEST_METHOD, scripts = "classpath:clearDb.sql") +@Import(JacksonConfiguration.class) class JobsControllerTest extends BaseTest { @MockitoBean @@ -77,12 +79,11 @@ void getJobs() throws Exception { get("/data-export-spring/jobs") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.totalRecords", is(8)), - jsonPath("$.jobRecords", hasSize(8)))); + jsonPath("$.jobRecords", hasSize(8))); } @Test @@ -93,12 +94,11 @@ void findSortedJobsByExportMethodName() throws Exception { get("/data-export-spring/jobs?limit=3&offset=0&query=(cql.allRecords=1)sortby jsonb.exportTypeSpecificParameters.vendorEdiOrdersExportConfig.configName/sort.descending") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.totalRecords", is(8)), - jsonPath("$.jobRecords", hasSize(3)))); + jsonPath("$.jobRecords", hasSize(3))); } @Test @@ -109,11 +109,10 @@ void notFoundJobs() throws Exception { get("/data-export-spring/jobs?limit=3&offset=0&query=!!sortby name/sort.descending") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isBadRequest(), content().contentType(MediaType.APPLICATION_JSON_VALUE), - jsonPath("$.errors[0].message", startsWith("IllegalArgumentException")))); + jsonPath("$.errors[0].message", startsWith("IllegalArgumentException"))); } @Test @@ -124,11 +123,10 @@ void findJobsByQueryDateRange() throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=(endTime>=2020-12-12T00:00:00.000 and endTime<=2020-12-13T23:59:59.999) sortby name/sort.descending") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), - jsonPath("$.totalRecords", is(0)))); + jsonPath("$.totalRecords", is(0))); } @Test @@ -139,12 +137,11 @@ void excludeJobById() throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=(id<>12ae5d0f-1525-44a1-a361-0bc9b88e8179 or name=*)") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.totalRecords", is(7)), - jsonPath("$.jobRecords", hasSize(7)))); + jsonPath("$.jobRecords", hasSize(7))); } @Test @@ -155,12 +152,11 @@ void findJobsBySourceOrDesc() throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=(source<>data-export-system-user or description==test-desc)") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.totalRecords", is(7)), - jsonPath("$.jobRecords", hasSize(7)))); + jsonPath("$.jobRecords", hasSize(7))); } @Test @@ -171,11 +167,10 @@ void findJobsByStrictDateRange() throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=(endTime>2020-12-12T00:00:00.000 and endTime<2020-12-13T23:59:59.999)") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), - jsonPath("$.totalRecords", is(0)))); + jsonPath("$.totalRecords", is(0))); } @Test @@ -186,11 +181,10 @@ void findJobsAttribute() throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=(metadata.endTime>2020-12-12T00:00:00.000)") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isBadRequest(), content().contentType(MediaType.APPLICATION_JSON_VALUE), - jsonPath("$.errors[0].message", startsWith("PathElementException")))); + jsonPath("$.errors[0].message", startsWith("PathElementException"))); } @Test @@ -201,13 +195,12 @@ void getJob() throws Exception { get("/data-export-spring/jobs/12ae5d0f-1525-44a1-a361-0bc9b88e8179") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.id", is("12ae5d0f-1525-44a1-a361-0bc9b88e8179")), jsonPath("$.status", is("SUCCESSFUL")), - jsonPath("$.outputFormat", is("Fees & Fines Bursar Report")))); + jsonPath("$.outputFormat", is("Fees & Fines Bursar Report"))); } @Test @@ -218,9 +211,8 @@ void shouldFailedDownloadWithNotFound() throws Exception { get("/data-export-spring/jobs/35ae5d0f-1525-42a1-a361-1bc9b88e8180/download") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( - status().is4xxClientError())); + .andExpectAll( + status().is4xxClientError()); } @Test @@ -234,9 +226,8 @@ void shouldFailedDownloadWithBadRequest() throws Exception { get("/data-export-spring/jobs/42ae5d0f-6425-82a1-a361-1bc9b88e8172/download") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( - status().is5xxServerError())); + .andExpectAll( + status().is5xxServerError()); } @Test @@ -247,11 +238,10 @@ void notFoundJob() throws Exception { get("/data-export-spring/jobs/12ae5d0f-1525-44a1-a361-0bc9b88eeeee") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isNotFound(), content().contentType(MediaType.APPLICATION_JSON_VALUE), - jsonPath("$.errors[0].message", startsWith("NotFoundException")))); + jsonPath("$.errors[0].message", startsWith("NotFoundException"))); } @Test @@ -263,13 +253,12 @@ void postBursarJob() throws Exception { .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders()) .content(JOB_BURSAR_REQUEST)) - .andExpect( - matchAll( + .andExpectAll( status().isCreated(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.type", is("BURSAR_FEES_FINES")), jsonPath("$.status", is("SCHEDULED")), - jsonPath("$.outputFormat", is("Fees & Fines Bursar Report")))); + jsonPath("$.outputFormat", is("Fees & Fines Bursar Report"))); } @Test @@ -282,13 +271,12 @@ void postCirculationJob() throws Exception { .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders()) .content(JOB_CIRCULATION_REQUEST)) - .andExpect( - matchAll( + .andExpectAll( status().isCreated(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.type", is("CIRCULATION_LOG")), jsonPath("$.status", is("SCHEDULED")), - jsonPath("$.outputFormat", is("Comma-Separated Values (CSV)")))); + jsonPath("$.outputFormat", is("Comma-Separated Values (CSV)"))); } @ParameterizedTest @@ -385,12 +373,11 @@ void findJobsByJSONBQuery(String query) throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=" + query) .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isOk(), content().contentType(MediaType.APPLICATION_JSON_VALUE), jsonPath("$.totalRecords", is(1)), - jsonPath("$.jobRecords", hasSize(1)))); + jsonPath("$.jobRecords", hasSize(1))); } @Test @@ -401,10 +388,9 @@ void shouldThrowExceptionIfJSONBQueryIsEmpty() throws Exception { get("/data-export-spring/jobs?limit=30&offset=0&query=jsonb==1 and type==\"EDIFACT_ORDERS_EXPORT\"") .contentType(MediaType.APPLICATION_JSON_VALUE) .headers(defaultHeaders())) - .andExpect( - matchAll( + .andExpectAll( status().isBadRequest(), - content().contentType(MediaType.APPLICATION_JSON_VALUE))); + content().contentType(MediaType.APPLICATION_JSON_VALUE)); } private static Stream getPayloadForJobWithoutRequiredParameters() { diff --git a/src/test/java/org/folio/des/exceptions/RestClientErrorHandlerTest.java b/src/test/java/org/folio/des/exceptions/RestClientErrorHandlerTest.java new file mode 100644 index 00000000..4694f99c --- /dev/null +++ b/src/test/java/org/folio/des/exceptions/RestClientErrorHandlerTest.java @@ -0,0 +1,81 @@ +package org.folio.des.exceptions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; + +import org.folio.des.CopilotGenerated; +import org.folio.spring.exception.NotFoundException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpResponse; + +@CopilotGenerated(model = "claude-4-5") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RestClientErrorHandlerTest { + + @Mock + private HttpRequest request; + @Mock + private ClientHttpResponse response; + @InjectMocks + private RestClientErrorHandler restClientErrorHandler; + + @Test + void shouldThrowNotFoundExceptionWhenStatusIs404() throws IOException { + when(request.getURI()).thenReturn(URI.create("http://localhost/test")); + when(response.getStatusCode()).thenReturn(HttpStatus.NOT_FOUND); + + assertThatThrownBy(() -> restClientErrorHandler.handle(request, response)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Not found: http://localhost/test"); + } + + @Test + void shouldThrowRuntimeExceptionWithBodyMessageWhenStatusIsNot404() throws IOException { + var errorBody = "Internal Server Error details"; + + when(request.getURI()).thenReturn(URI.create("http://localhost/resource")); + when(response.getStatusCode()).thenReturn(HttpStatus.INTERNAL_SERVER_ERROR); + when(response.getBody()).thenReturn(new ByteArrayInputStream(errorBody.getBytes())); + + assertThatThrownBy(() -> restClientErrorHandler.handle(request, response)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("http://localhost/resource") + .hasMessageContaining(errorBody); + } + + @Test + void shouldThrowRuntimeExceptionWithUnknownErrorWhenBodyIsBlank() throws IOException { + when(request.getURI()).thenReturn(URI.create("http://localhost/resource")); + when(response.getStatusCode()).thenReturn(HttpStatus.INTERNAL_SERVER_ERROR); + when(response.getBody()).thenReturn(new ByteArrayInputStream(" ".getBytes())); + + assertThatThrownBy(() -> restClientErrorHandler.handle(request, response)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("http://localhost/resource") + .hasMessageContaining("Unknown error"); + } + + @Test + void shouldThrowRuntimeExceptionWhenBodyThrowsIOException() throws IOException { + when(request.getURI()).thenReturn(URI.create("http://localhost/resource")); + when(response.getStatusCode()).thenReturn(HttpStatus.INTERNAL_SERVER_ERROR); + when(response.getBody()).thenThrow(new IOException("stream error")); + + assertThatThrownBy(() -> restClientErrorHandler.handle(request, response)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to get reason for error: stream error"); + } +} diff --git a/src/test/java/org/folio/des/service/JobExecutionServiceTest.java b/src/test/java/org/folio/des/service/JobExecutionServiceTest.java index 84b06040..3d5b2063 100644 --- a/src/test/java/org/folio/des/service/JobExecutionServiceTest.java +++ b/src/test/java/org/folio/des/service/JobExecutionServiceTest.java @@ -2,7 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -import java.util.HashMap; +import java.util.HashSet; + import org.folio.de.entity.Job; import org.folio.des.builder.job.JobCommandBuilderResolver; import org.folio.des.domain.dto.ExportType; @@ -30,6 +31,6 @@ void shouldPrepareStartJobCommandWithNoJobCommandBuilder() { var command = jobExecutionService.prepareStartJobCommand(job); - assertEquals(new HashMap<>(), command.getJobParameters().getParameters()); + assertEquals(new HashSet<>(), command.getJobParameters().parameters()); } } diff --git a/src/test/java/org/folio/des/service/JobServiceTest.java b/src/test/java/org/folio/des/service/JobServiceTest.java index 712a299d..bb134851 100644 --- a/src/test/java/org/folio/des/service/JobServiceTest.java +++ b/src/test/java/org/folio/des/service/JobServiceTest.java @@ -127,8 +127,8 @@ void testResendJob() { job.setFileNames(list); internalJobService.resendExportedFile(jobDto.getId()); JobCommand command = jobExecutionService.prepareResendJobCommand(job); - Assertions.assertEquals("TestFile.csv", command.getJobParameters().getParameters().get("FILE_NAME").getValue()); - Assertions.assertNotNull(command.getJobParameters().getParameters().get("EDIFACT_ORDERS_EXPORT")); + Assertions.assertEquals("TestFile.csv", command.getJobParameters().getParameter("FILE_NAME").value()); + Assertions.assertNotNull(command.getJobParameters().getParameter("EDIFACT_ORDERS_EXPORT")); } @Test diff --git a/src/test/java/org/folio/des/service/config/impl/ExportTypeBasedConfigManagerTest.java b/src/test/java/org/folio/des/service/config/impl/ExportTypeBasedConfigManagerTest.java index 5f373bd2..93c66780 100644 --- a/src/test/java/org/folio/des/service/config/impl/ExportTypeBasedConfigManagerTest.java +++ b/src/test/java/org/folio/des/service/config/impl/ExportTypeBasedConfigManagerTest.java @@ -31,8 +31,10 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.jdbc.Sql; @TestPropertySource(properties = "spring.jpa.properties.hibernate.default_schema=diku_mod_data_export_spring") +@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS, scripts = "classpath:init.sql") class ExportTypeBasedConfigManagerTest extends BaseTest { @MockitoSpyBean diff --git a/src/test/java/org/folio/des/support/BaseTest.java b/src/test/java/org/folio/des/support/BaseTest.java index 942f13b7..d7200502 100644 --- a/src/test/java/org/folio/des/support/BaseTest.java +++ b/src/test/java/org/folio/des/support/BaseTest.java @@ -17,9 +17,9 @@ import org.quartz.Scheduler; import org.quartz.SchedulerException; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.http.HttpHeaders; @@ -43,8 +43,8 @@ import lombok.SneakyThrows; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", "spring.liquibase.enabled=true"}) @ContextConfiguration(initializers = BaseTest.DockerPostgreDataSourceInitializer.class) @AutoConfigureMockMvc @Testcontainers @@ -52,6 +52,7 @@ @EnableKafka @DirtiesContext(classMode = ClassMode.AFTER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS) +@AutoConfigureRestTestClient public abstract class BaseTest { public static final int WIRE_MOCK_PORT = TestSocketUtils.findAvailableTcpPort(); diff --git a/src/test/java/org/folio/des/validator/ExportConfigValidatorResolverTest.java b/src/test/java/org/folio/des/validator/ExportConfigValidatorResolverTest.java index 3eaedf2a..f783d536 100644 --- a/src/test/java/org/folio/des/validator/ExportConfigValidatorResolverTest.java +++ b/src/test/java/org/folio/des/validator/ExportConfigValidatorResolverTest.java @@ -5,23 +5,28 @@ import java.util.Optional; +import org.folio.des.client.DataExportSpringClient; +import org.folio.des.client.ExportWorkerClient; import org.folio.des.config.JacksonConfiguration; import org.folio.des.config.ServiceConfiguration; import org.folio.des.config.scheduling.QuartzSchemaInitializer; import org.folio.des.domain.dto.ExportType; import org.folio.des.domain.dto.ExportTypeSpecificParameters; +import org.folio.spring.client.AuthnClient; +import org.folio.spring.client.PermissionsClient; +import org.folio.spring.client.UsersClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.quartz.Scheduler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.validation.Validator; +import org.springframework.web.client.RestClient; @SpringBootTest(classes = {JacksonConfiguration.class, ServiceConfiguration.class}) -@EnableAutoConfiguration(exclude = {BatchAutoConfiguration.class}) +@EnableAutoConfiguration class ExportConfigValidatorResolverTest { @Autowired @@ -30,6 +35,18 @@ class ExportConfigValidatorResolverTest { private Scheduler scheduler; @MockitoBean private QuartzSchemaInitializer quartzSchemaInitializer; + @MockitoBean + private ExportWorkerClient exportWorkerClient; + @MockitoBean + private DataExportSpringClient dataExportSpringClient; + @MockitoBean + private RestClient restClient; + @MockitoBean + private AuthnClient authnClient; + @MockitoBean + private UsersClient usersClient; + @MockitoBean + private PermissionsClient permissionsClient; @Test @DisplayName("Should retrieve validator for specific configuration parameter if validator is registered in the resolver") diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index 5752c614..c4c4208d 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -1,3 +1,5 @@ folio: system: password: testpassword + exchange: + enabled: true \ No newline at end of file diff --git a/src/test/resources/init.sql b/src/test/resources/init.sql new file mode 100644 index 00000000..6f1fdc3a --- /dev/null +++ b/src/test/resources/init.sql @@ -0,0 +1,4 @@ +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text AS $$ + SELECT unaccent('unaccent', $1) +$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT; \ No newline at end of file diff --git a/src/test/resources/mappings/authn.json b/src/test/resources/mappings/authn.json index 620dd38e..86e0a5db 100644 --- a/src/test/resources/mappings/authn.json +++ b/src/test/resources/mappings/authn.json @@ -51,7 +51,12 @@ { "request": { "method": "POST", - "url": "/perms/users/a85c45b7-d427-4122-8532-5570219c5e59/permissions?indexField=userId" + "urlPathPattern": "/perms/users/.*?/permissions", + "queryParameters": { + "indexField": { + "equalTo": "userId" + } + } }, "response": { "status": 200, @@ -63,10 +68,10 @@ { "request": { "method": "GET", - "urlPathPattern": "/perms/users", + "urlPathPattern": "/perms/users/.*?/permissions", "queryParameters": { - "query": { - "matches": ".*" + "indexField": { + "equalTo": "userId" } } }, @@ -75,7 +80,7 @@ "headers": { "Content-Type": "application/json" }, - "body": "{\n \"permissionUsers\": [\n {\n \"id\": \"c3795dfc-76d6-4f25-83ac-05f5107fa281\",\n \"userId\": \"a85c45b7-d427-4122-8532-5570219c5e59\",\n \"permissions\": [],\n \"metadata\": {\n \"createdDate\": \"2021-02-03T11:02:42.457+00:00\",\n \"updatedDate\": \"2021-02-03T11:02:42.457+00:00\"\n }\n }\n ],\n \"totalRecords\": 1,\n \"resultInfo\": {\n \"totalRecords\": 1,\n \"facets\": [],\n \"diagnostics\": []\n }\n}" + "body": "{\n \"results\": [\n \"a85c45b7-d427-4122-8532-5570219c5e59\"\n ],\n \"totalRecords\": 1\n}" } } ]