diff --git a/pom.xml b/pom.xml
new file mode 100644
index 00000000..14cd9f11
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,115 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.0
+
+
+
+ com.zara
+ similar-products-api
+ 1.0.0
+ jar
+
+
+ 17
+ 17
+ 17
+ 1.19.3
+ 1.2.1
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ mockserver
+ ${testcontainers.version}
+ test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ ${archunit.version}
+ test
+
+
+ org.awaitility
+ awaitility
+ test
+
+
+ com.squareup.okhttp3
+ mockwebserver
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*Test.java
+ **/*Tests.java
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ **/*IT.java
+ **/*IntegrationTest.java
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/SimilarProductsApplication.java b/src/main/java/com/zara/similarproducts/SimilarProductsApplication.java
new file mode 100644
index 00000000..27bb96af
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/SimilarProductsApplication.java
@@ -0,0 +1,12 @@
+package com.zara.similarproducts;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SimilarProductsApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SimilarProductsApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/application/port/in/GetSimilarProductsUseCase.java b/src/main/java/com/zara/similarproducts/application/port/in/GetSimilarProductsUseCase.java
new file mode 100644
index 00000000..2582f6a4
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/application/port/in/GetSimilarProductsUseCase.java
@@ -0,0 +1,10 @@
+package com.zara.similarproducts.application.port.in;
+
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.domain.model.SimilarProducts;
+import reactor.core.publisher.Mono;
+
+public interface GetSimilarProductsUseCase {
+
+ Mono execute(ProductId productId);
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/application/port/out/ProductRepository.java b/src/main/java/com/zara/similarproducts/application/port/out/ProductRepository.java
new file mode 100644
index 00000000..ab8722a2
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/application/port/out/ProductRepository.java
@@ -0,0 +1,10 @@
+package com.zara.similarproducts.application.port.out;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.ProductId;
+import reactor.core.publisher.Mono;
+
+public interface ProductRepository {
+
+ Mono findById(ProductId productId);
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/application/port/out/SimilarProductsRepository.java b/src/main/java/com/zara/similarproducts/application/port/out/SimilarProductsRepository.java
new file mode 100644
index 00000000..353ddf23
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/application/port/out/SimilarProductsRepository.java
@@ -0,0 +1,9 @@
+package com.zara.similarproducts.application.port.out;
+
+import com.zara.similarproducts.domain.model.ProductId;
+import reactor.core.publisher.Flux;
+
+public interface SimilarProductsRepository {
+
+ Flux findSimilarProductIds(ProductId productId);
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/application/usecase/GetSimilarProductsUseCaseImpl.java b/src/main/java/com/zara/similarproducts/application/usecase/GetSimilarProductsUseCaseImpl.java
new file mode 100644
index 00000000..2d45a305
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/application/usecase/GetSimilarProductsUseCaseImpl.java
@@ -0,0 +1,37 @@
+package com.zara.similarproducts.application.usecase;
+
+import com.zara.similarproducts.application.port.in.GetSimilarProductsUseCase;
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.domain.model.SimilarProducts;
+import com.zara.similarproducts.domain.service.SimilarProductsDomainService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+import java.time.Duration;
+
+@Service
+public class GetSimilarProductsUseCaseImpl implements GetSimilarProductsUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(GetSimilarProductsUseCaseImpl.class);
+ private static final Duration TIMEOUT = Duration.ofSeconds(10);
+
+ private final SimilarProductsDomainService domainService;
+
+ public GetSimilarProductsUseCaseImpl(SimilarProductsDomainService domainService) {
+ this.domainService = domainService;
+ }
+
+ @Override
+ public Mono execute(ProductId productId) {
+ logger.info("Executing GetSimilarProducts use case for product: {}", productId.value());
+
+ return domainService.findSimilarProducts(productId)
+ .timeout(TIMEOUT)
+ .doOnSuccess(result -> logger.info("Found {} similar products for product: {}",
+ result.size(), productId.value()))
+ .doOnError(error -> logger.error("Error finding similar products for product: {}",
+ productId.value(), error));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/DomainException.java b/src/main/java/com/zara/similarproducts/domain/model/DomainException.java
new file mode 100644
index 00000000..cc5807dd
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/DomainException.java
@@ -0,0 +1,14 @@
+package com.zara.similarproducts.domain.model;
+
+public abstract class DomainException extends RuntimeException {
+
+ protected DomainException(String message) {
+ super(message);
+ }
+
+ protected DomainException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public abstract String getErrorCode();
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/ExternalServiceException.java b/src/main/java/com/zara/similarproducts/domain/model/ExternalServiceException.java
new file mode 100644
index 00000000..db0a979e
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/ExternalServiceException.java
@@ -0,0 +1,19 @@
+package com.zara.similarproducts.domain.model;
+
+public class ExternalServiceException extends DomainException {
+
+ private static final String ERROR_CODE = "EXTERNAL_SERVICE_ERROR";
+
+ public ExternalServiceException(String message) {
+ super(message);
+ }
+
+ public ExternalServiceException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ @Override
+ public String getErrorCode() {
+ return ERROR_CODE;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/InvalidProductIdException.java b/src/main/java/com/zara/similarproducts/domain/model/InvalidProductIdException.java
new file mode 100644
index 00000000..3b2b7df1
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/InvalidProductIdException.java
@@ -0,0 +1,21 @@
+package com.zara.similarproducts.domain.model;
+
+public class InvalidProductIdException extends DomainException {
+
+ private static final String ERROR_CODE = "INVALID_PRODUCT_ID";
+ private final String productId;
+
+ public InvalidProductIdException(String productId) {
+ super("Invalid product ID format: " + productId);
+ this.productId = productId;
+ }
+
+ public String getProductId() {
+ return productId;
+ }
+
+ @Override
+ public String getErrorCode() {
+ return ERROR_CODE;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/Product.java b/src/main/java/com/zara/similarproducts/domain/model/Product.java
new file mode 100644
index 00000000..8d77a14d
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/Product.java
@@ -0,0 +1,29 @@
+package com.zara.similarproducts.domain.model;
+
+import java.math.BigDecimal;
+import java.util.Objects;
+
+public record Product(
+ ProductId id,
+ String name,
+ BigDecimal price,
+ boolean availability
+) {
+
+ public Product {
+ Objects.requireNonNull(id, "Product ID cannot be null");
+ Objects.requireNonNull(name, "Product name cannot be null");
+ Objects.requireNonNull(price, "Product price cannot be null");
+
+ if (name.isBlank()) {
+ throw new IllegalArgumentException("Product name cannot be blank");
+ }
+ if (price.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("Product price cannot be negative");
+ }
+ }
+
+ public static Product of(ProductId id, String name, BigDecimal price, boolean availability) {
+ return new Product(id, name, price, availability);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/ProductId.java b/src/main/java/com/zara/similarproducts/domain/model/ProductId.java
new file mode 100644
index 00000000..e51f09ef
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/ProductId.java
@@ -0,0 +1,20 @@
+package com.zara.similarproducts.domain.model;
+
+import java.util.Objects;
+
+public record ProductId(String value) {
+
+ public ProductId {
+ Objects.requireNonNull(value, "Product ID cannot be null");
+ if (value.isBlank()) {
+ throw new InvalidProductIdException(value);
+ }
+ if (!value.matches("^[0-9]+$")) {
+ throw new InvalidProductIdException(value);
+ }
+ }
+
+ public static ProductId of(String value) {
+ return new ProductId(value);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/ProductNotFoundException.java b/src/main/java/com/zara/similarproducts/domain/model/ProductNotFoundException.java
new file mode 100644
index 00000000..39de534d
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/ProductNotFoundException.java
@@ -0,0 +1,21 @@
+package com.zara.similarproducts.domain.model;
+
+public class ProductNotFoundException extends DomainException {
+
+ private static final String ERROR_CODE = "PRODUCT_NOT_FOUND";
+ private final ProductId productId;
+
+ public ProductNotFoundException(ProductId productId) {
+ super("Product not found: " + productId.value());
+ this.productId = productId;
+ }
+
+ public ProductId getProductId() {
+ return productId;
+ }
+
+ @Override
+ public String getErrorCode() {
+ return ERROR_CODE;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/model/SimilarProducts.java b/src/main/java/com/zara/similarproducts/domain/model/SimilarProducts.java
new file mode 100644
index 00000000..428c791c
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/model/SimilarProducts.java
@@ -0,0 +1,28 @@
+package com.zara.similarproducts.domain.model;
+
+import java.util.List;
+import java.util.Objects;
+
+public record SimilarProducts(List products) {
+
+ public SimilarProducts {
+ Objects.requireNonNull(products, "Products list cannot be null");
+ }
+
+ public static SimilarProducts of(List products) {
+ Objects.requireNonNull(products, "Products list cannot be null");
+ return new SimilarProducts(List.copyOf(products));
+ }
+
+ public static SimilarProducts empty() {
+ return new SimilarProducts(List.of());
+ }
+
+ public boolean isEmpty() {
+ return products.isEmpty();
+ }
+
+ public int size() {
+ return products.size();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/domain/service/SimilarProductsDomainService.java b/src/main/java/com/zara/similarproducts/domain/service/SimilarProductsDomainService.java
new file mode 100644
index 00000000..799bdc78
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/domain/service/SimilarProductsDomainService.java
@@ -0,0 +1,65 @@
+package com.zara.similarproducts.domain.service;
+
+import com.zara.similarproducts.domain.model.ExternalServiceException;
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.domain.model.ProductNotFoundException;
+import com.zara.similarproducts.domain.model.SimilarProducts;
+import com.zara.similarproducts.application.port.out.ProductRepository;
+import com.zara.similarproducts.application.port.out.SimilarProductsRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.concurrent.TimeoutException;
+
+public class SimilarProductsDomainService {
+
+ private static final Logger logger = LoggerFactory.getLogger(SimilarProductsDomainService.class);
+
+ private final SimilarProductsRepository similarProductsRepository;
+ private final ProductRepository productRepository;
+
+ public SimilarProductsDomainService(
+ SimilarProductsRepository similarProductsRepository,
+ ProductRepository productRepository) {
+ this.similarProductsRepository = similarProductsRepository;
+ this.productRepository = productRepository;
+ }
+
+ public Mono findSimilarProducts(ProductId productId) {
+ logger.debug("Finding similar products for: {}", productId.value());
+
+ return similarProductsRepository.findSimilarProductIds(productId)
+ .collectList()
+ .onErrorMap(TimeoutException.class, ex ->
+ new ExternalServiceException("Timeout retrieving similar product IDs", ex))
+ .onErrorMap(Exception.class, ex ->
+ new ExternalServiceException("Error retrieving similar product IDs", ex))
+ .flatMap(ids -> {
+ if (ids.isEmpty()) {
+ logger.debug("No similar products found for: {}", productId.value());
+ return Mono.error(new ProductNotFoundException(productId));
+ }
+
+ logger.debug("Found {} similar product IDs for: {}", ids.size(), productId.value());
+ return Flux.fromIterable(ids)
+ .flatMap(this::findProductWithErrorHandling)
+ .collectList()
+ .map(SimilarProducts::of);
+ });
+ }
+
+ private Mono findProductWithErrorHandling(ProductId productId) {
+ return productRepository.findById(productId)
+ .onErrorResume(TimeoutException.class, ex -> {
+ logger.warn("Timeout retrieving product details for: {}", productId.value());
+ return Mono.empty();
+ })
+ .onErrorResume(Exception.class, ex -> {
+ logger.warn("Error retrieving product details for: {}", productId.value(), ex);
+ return Mono.empty();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/controller/SimilarProductsController.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/controller/SimilarProductsController.java
new file mode 100644
index 00000000..0be6036d
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/controller/SimilarProductsController.java
@@ -0,0 +1,51 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.controller;
+
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.application.port.in.GetSimilarProductsUseCase;
+import com.zara.similarproducts.domain.model.ProductNotFoundException;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.dto.ProductDetailResponse;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.mapper.SimilarProductsMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/product")
+public class SimilarProductsController {
+
+ private static final Logger logger = LoggerFactory.getLogger(SimilarProductsController.class);
+
+ private final GetSimilarProductsUseCase getSimilarProductsUseCase;
+ private final SimilarProductsMapper mapper;
+
+ public SimilarProductsController(
+ GetSimilarProductsUseCase getSimilarProductsUseCase,
+ SimilarProductsMapper mapper) {
+ this.getSimilarProductsUseCase = getSimilarProductsUseCase;
+ this.mapper = mapper;
+ }
+ @GetMapping("/{productId}/similar")
+ public Mono>> getSimilarProducts(
+ @PathVariable String productId) {
+
+ logger.info("Received request for similar products of product: {}", productId);
+
+ return getSimilarProductsUseCase.execute(ProductId.of(productId))
+ .map(mapper::toResponse)
+ .map(ResponseEntity::ok)
+ .doOnSuccess(response -> logger.info("Returning {} similar products for product: {}",
+ response.getBody().size(), productId))
+ .doOnError(ex -> logger.error("Error fetching similar products for {}", productId, ex))
+ .onErrorResume(ProductNotFoundException.class, ex ->
+ Mono.just(ResponseEntity.notFound().build())
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/dto/ErrorResponse.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/dto/ErrorResponse.java
new file mode 100644
index 00000000..7c889179
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/dto/ErrorResponse.java
@@ -0,0 +1,18 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.Instant;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record ErrorResponse(
+ String error,
+ String message,
+ String path,
+ Instant timestamp
+) {
+
+ public static ErrorResponse of(String error, String message, String path) {
+ return new ErrorResponse(error, message, path, Instant.now());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/dto/ProductDetailResponse.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/dto/ProductDetailResponse.java
new file mode 100644
index 00000000..8610179f
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/dto/ProductDetailResponse.java
@@ -0,0 +1,12 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.math.BigDecimal;
+
+public record ProductDetailResponse(
+ @JsonProperty("id") String id,
+ @JsonProperty("name") String name,
+ @JsonProperty("price") BigDecimal price,
+ @JsonProperty("availability") boolean availability
+) {}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/exception/GlobalExceptionHandler.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/exception/GlobalExceptionHandler.java
new file mode 100644
index 00000000..c220e0dc
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/exception/GlobalExceptionHandler.java
@@ -0,0 +1,107 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.exception;
+
+import com.zara.similarproducts.domain.model.DomainException;
+import com.zara.similarproducts.domain.model.ExternalServiceException;
+import com.zara.similarproducts.domain.model.InvalidProductIdException;
+import com.zara.similarproducts.domain.model.ProductNotFoundException;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.dto.ErrorResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.concurrent.TimeoutException;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ @ExceptionHandler(ProductNotFoundException.class)
+ public Mono> handleProductNotFound(
+ ProductNotFoundException ex, ServerWebExchange exchange) {
+ logger.warn("Product not found: {}", ex.getProductId().value());
+
+ ErrorResponse error = ErrorResponse.of(
+ ex.getErrorCode(),
+ ex.getMessage(),
+ exchange.getRequest().getPath().value()
+ );
+
+ return Mono.just(ResponseEntity.status(HttpStatus.NOT_FOUND).body(error));
+ }
+
+ @ExceptionHandler(InvalidProductIdException.class)
+ public Mono> handleInvalidProductId(
+ InvalidProductIdException ex, ServerWebExchange exchange) {
+ logger.warn("Invalid product ID: {}", ex.getProductId());
+
+ ErrorResponse error = ErrorResponse.of(
+ ex.getErrorCode(),
+ ex.getMessage(),
+ exchange.getRequest().getPath().value()
+ );
+
+ return Mono.just(ResponseEntity.badRequest().body(error));
+ }
+
+ @ExceptionHandler(ExternalServiceException.class)
+ public Mono> handleExternalService(
+ ExternalServiceException ex, ServerWebExchange exchange) {
+ logger.error("External service error: {}", ex.getMessage(), ex);
+
+ ErrorResponse error = ErrorResponse.of(
+ ex.getErrorCode(),
+ "Service temporarily unavailable",
+ exchange.getRequest().getPath().value()
+ );
+
+ return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error));
+ }
+
+ @ExceptionHandler(TimeoutException.class)
+ public Mono> handleTimeout(
+ TimeoutException ex, ServerWebExchange exchange) {
+ logger.error("Request timeout", ex);
+
+ ErrorResponse error = ErrorResponse.of(
+ "REQUEST_TIMEOUT",
+ "Request timeout",
+ exchange.getRequest().getPath().value()
+ );
+
+ return Mono.just(ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body(error));
+ }
+
+ @ExceptionHandler(DomainException.class)
+ public Mono> handleDomainException(
+ DomainException ex, ServerWebExchange exchange) {
+ logger.warn("Domain error: {}", ex.getMessage());
+
+ ErrorResponse error = ErrorResponse.of(
+ ex.getErrorCode(),
+ ex.getMessage(),
+ exchange.getRequest().getPath().value()
+ );
+
+ return Mono.just(ResponseEntity.badRequest().body(error));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public Mono> handleGenericException(
+ Exception ex, ServerWebExchange exchange) {
+ logger.error("Unexpected error", ex);
+
+ ErrorResponse error = ErrorResponse.of(
+ "INTERNAL_SERVER_ERROR",
+ "Internal server error",
+ exchange.getRequest().getPath().value()
+ );
+
+ return Mono.just(ResponseEntity.internalServerError().body(error));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/mapper/SimilarProductsMapper.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/mapper/SimilarProductsMapper.java
new file mode 100644
index 00000000..1a649fae
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/in/rest/mapper/SimilarProductsMapper.java
@@ -0,0 +1,27 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.mapper;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.SimilarProducts;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.dto.ProductDetailResponse;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+public class SimilarProductsMapper {
+
+ public List toResponse(SimilarProducts similarProducts) {
+ return similarProducts.products().stream()
+ .map(this::toProductDetailResponse)
+ .toList();
+ }
+
+ private ProductDetailResponse toProductDetailResponse(Product product) {
+ return new ProductDetailResponse(
+ product.id().value(),
+ product.name(),
+ product.price(),
+ product.availability()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductMapper.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductMapper.java
new file mode 100644
index 00000000..64875ec0
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductMapper.java
@@ -0,0 +1,19 @@
+package com.zara.similarproducts.infrastructure.adapter.out.external;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.infrastructure.adapter.out.external.dto.ExternalProductResponse;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ExternalProductMapper {
+
+ public Product toDomain(ExternalProductResponse response) {
+ return Product.of(
+ ProductId.of(response.id()),
+ response.name(),
+ response.price(),
+ response.availability()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductRepositoryAdapter.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductRepositoryAdapter.java
new file mode 100644
index 00000000..9270ed03
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductRepositoryAdapter.java
@@ -0,0 +1,51 @@
+package com.zara.similarproducts.infrastructure.adapter.out.external;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.application.port.out.ProductRepository;
+import com.zara.similarproducts.infrastructure.adapter.out.external.dto.ExternalProductResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+import reactor.core.publisher.Mono;
+
+import java.time.Duration;
+
+@Component
+public class ExternalProductRepositoryAdapter implements ProductRepository {
+
+ private static final Logger logger = LoggerFactory.getLogger(ExternalProductRepositoryAdapter.class);
+
+ private final WebClient webClient;
+ private final Duration timeout;
+ private final ExternalProductMapper mapper;
+
+ public ExternalProductRepositoryAdapter(
+ WebClient webClient,
+ @Value("${app.external-api.timeout.product-detail:8s}") Duration timeout,
+ ExternalProductMapper mapper) {
+ this.webClient = webClient;
+ this.timeout = timeout;
+ this.mapper = mapper;
+ }
+
+ @Override
+ public Mono findById(ProductId productId) {
+ logger.debug("Fetching product details for product: {}", productId.value());
+
+ return webClient.get()
+ .uri("/product/{productId}", productId.value())
+ .retrieve()
+ .bodyToMono(ExternalProductResponse.class)
+ .timeout(timeout)
+ .map(mapper::toDomain)
+ .doOnNext(product -> logger.debug("Found product: {}", product.id().value()))
+ .doOnError(WebClientResponseException.NotFound.class,
+ error -> logger.debug("Product not found: {}", productId.value()))
+ .doOnError(error -> !(error instanceof WebClientResponseException.NotFound),
+ error -> logger.warn("Error fetching product {}: {}", productId.value(), error.getMessage()));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalSimilarProductsRepositoryAdapter.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalSimilarProductsRepositoryAdapter.java
new file mode 100644
index 00000000..79343733
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalSimilarProductsRepositoryAdapter.java
@@ -0,0 +1,49 @@
+package com.zara.similarproducts.infrastructure.adapter.out.external;
+
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.application.port.out.SimilarProductsRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
+
+import java.time.Duration;
+
+@Component
+public class ExternalSimilarProductsRepositoryAdapter implements SimilarProductsRepository {
+
+ private static final Logger logger = LoggerFactory.getLogger(ExternalSimilarProductsRepositoryAdapter.class);
+
+ private final WebClient webClient;
+ private final Duration timeout;
+
+ public ExternalSimilarProductsRepositoryAdapter(
+ WebClient webClient,
+ @Value("${app.external-api.timeout.similar-ids:2s}") Duration timeout) {
+ this.webClient = webClient;
+ this.timeout = timeout;
+ }
+
+ @Override
+ public Flux findSimilarProductIds(ProductId productId) {
+ logger.debug("Fetching similar product IDs for product: {}", productId.value());
+
+ return webClient.get()
+ .uri("/product/{productId}/similarids", productId.value())
+ .retrieve()
+ .bodyToMono(String[].class)
+ .timeout(timeout)
+ .flatMapMany(ids -> Flux.fromArray(ids))
+ .map(ProductId::of)
+ .doOnNext(id -> logger.debug("Found similar product ID: {}", id.value()))
+ .doOnError(error -> logger.warn("Error fetching similar product IDs for product {}: {}",
+ productId.value(), error.getMessage()))
+ .onErrorResume(org.springframework.web.reactive.function.client.WebClientResponseException.NotFound.class,
+ ex -> {
+ logger.debug("Product {} not found, returning empty similar products", productId.value());
+ return Flux.empty();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/dto/ExternalProductResponse.java b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/dto/ExternalProductResponse.java
new file mode 100644
index 00000000..371eb49e
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/adapter/out/external/dto/ExternalProductResponse.java
@@ -0,0 +1,12 @@
+package com.zara.similarproducts.infrastructure.adapter.out.external.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.math.BigDecimal;
+
+public record ExternalProductResponse(
+ @JsonProperty("id") String id,
+ @JsonProperty("name") String name,
+ @JsonProperty("price") BigDecimal price,
+ @JsonProperty("availability") boolean availability
+) {}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/config/DomainConfig.java b/src/main/java/com/zara/similarproducts/infrastructure/config/DomainConfig.java
new file mode 100644
index 00000000..67c53531
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/config/DomainConfig.java
@@ -0,0 +1,18 @@
+package com.zara.similarproducts.infrastructure.config;
+
+import com.zara.similarproducts.application.port.out.ProductRepository;
+import com.zara.similarproducts.application.port.out.SimilarProductsRepository;
+import com.zara.similarproducts.domain.service.SimilarProductsDomainService;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class DomainConfig {
+
+ @Bean
+ public SimilarProductsDomainService similarProductsDomainService(
+ SimilarProductsRepository similarProductsRepository,
+ ProductRepository productRepository) {
+ return new SimilarProductsDomainService(similarProductsRepository, productRepository);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/zara/similarproducts/infrastructure/config/WebClientConfig.java b/src/main/java/com/zara/similarproducts/infrastructure/config/WebClientConfig.java
new file mode 100644
index 00000000..7225e434
--- /dev/null
+++ b/src/main/java/com/zara/similarproducts/infrastructure/config/WebClientConfig.java
@@ -0,0 +1,30 @@
+package com.zara.similarproducts.infrastructure.config;
+
+import io.netty.channel.ChannelOption;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+@Configuration
+public class WebClientConfig {
+
+ @Bean
+ public WebClient webClient(@Value("${app.external-api.base-url:http://localhost:3001}") String baseUrl) {
+ HttpClient httpClient = HttpClient.create()
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
+ .doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS)))
+ .responseTimeout(Duration.ofSeconds(15));
+
+ return WebClient.builder()
+ .baseUrl(baseUrl)
+ .clientConnector(new ReactorClientHttpConnector(httpClient))
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 00000000..c000df93
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,34 @@
+server:
+ port: 5000
+
+spring:
+ application:
+ name: similar-products-api
+
+logging:
+ level:
+ com.zara.similarproducts: INFO
+ org.springframework.web.reactive.function.client: DEBUG
+ pattern:
+ console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
+
+management:
+ server:
+ port: 5001
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics
+ endpoint:
+ health:
+ show-details: always
+
+app:
+ external-api:
+ base-url: http://localhost:3001
+ timeout:
+ similar-ids: 2s
+ product-detail: 8s
+ overall: 10s
+ concurrency:
+ max-parallel: 10
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/application/usecase/GetSimilarProductsUseCaseImplTest.java b/src/test/java/com/zara/similarproducts/application/usecase/GetSimilarProductsUseCaseImplTest.java
new file mode 100644
index 00000000..b3a9c097
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/application/usecase/GetSimilarProductsUseCaseImplTest.java
@@ -0,0 +1,90 @@
+package com.zara.similarproducts.application.usecase;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.domain.model.ProductNotFoundException;
+import com.zara.similarproducts.domain.model.SimilarProducts;
+import com.zara.similarproducts.domain.service.SimilarProductsDomainService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class GetSimilarProductsUseCaseImplTest {
+
+ @Mock
+ private SimilarProductsDomainService domainService;
+
+ private GetSimilarProductsUseCaseImpl useCase;
+
+ @BeforeEach
+ void setUp() {
+ useCase = new GetSimilarProductsUseCaseImpl(domainService);
+ }
+
+ @Test
+ void shouldExecuteSuccessfully() {
+ ProductId productId = ProductId.of("1");
+ Product product = createProduct("2", "Similar Product");
+ SimilarProducts similarProducts = SimilarProducts.of(List.of(product));
+
+ when(domainService.findSimilarProducts(productId))
+ .thenReturn(Mono.just(similarProducts));
+
+ StepVerifier.create(useCase.execute(productId))
+ .expectNext(similarProducts)
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldPropagateProductNotFoundException() {
+ ProductId productId = ProductId.of("1");
+
+ when(domainService.findSimilarProducts(productId))
+ .thenReturn(Mono.error(new ProductNotFoundException(productId)));
+
+ StepVerifier.create(useCase.execute(productId))
+ .expectError(ProductNotFoundException.class)
+ .verify();
+ }
+
+ @Test
+ void shouldApplyTimeout() {
+ ProductId productId = ProductId.of("1");
+
+ when(domainService.findSimilarProducts(productId))
+ .thenReturn(Mono.never()); // Never completes
+
+ StepVerifier.create(useCase.execute(productId))
+ .expectError(TimeoutException.class)
+ .verify(Duration.ofSeconds(15));
+ }
+
+ @Test
+ void shouldPropagateGenericErrors() {
+ ProductId productId = ProductId.of("1");
+ RuntimeException error = new RuntimeException("Unexpected error");
+
+ when(domainService.findSimilarProducts(productId))
+ .thenReturn(Mono.error(error));
+
+ StepVerifier.create(useCase.execute(productId))
+ .expectError(RuntimeException.class)
+ .verify();
+ }
+
+ private Product createProduct(String id, String name) {
+ return Product.of(ProductId.of(id), name, BigDecimal.valueOf(10.0), true);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/domain/model/ExceptionTest.java b/src/test/java/com/zara/similarproducts/domain/model/ExceptionTest.java
new file mode 100644
index 00000000..552f5f3c
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/domain/model/ExceptionTest.java
@@ -0,0 +1,48 @@
+package com.zara.similarproducts.domain.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+class ExceptionTest {
+
+ @Test
+ void productNotFoundExceptionShouldHaveCorrectProperties() {
+ ProductId productId = ProductId.of("123");
+ ProductNotFoundException exception = new ProductNotFoundException(productId);
+
+ assertThat(exception.getProductId()).isEqualTo(productId);
+ assertThat(exception.getMessage()).isEqualTo("Product not found: 123");
+ assertThat(exception.getErrorCode()).isEqualTo("PRODUCT_NOT_FOUND");
+ }
+
+ @Test
+ void invalidProductIdExceptionShouldHaveCorrectProperties() {
+ String invalidId = "abc";
+ InvalidProductIdException exception = new InvalidProductIdException(invalidId);
+
+ assertThat(exception.getProductId()).isEqualTo(invalidId);
+ assertThat(exception.getMessage()).isEqualTo("Invalid product ID format: abc");
+ assertThat(exception.getErrorCode()).isEqualTo("INVALID_PRODUCT_ID");
+ }
+
+ @Test
+ void externalServiceExceptionShouldHaveCorrectProperties() {
+ String message = "Service unavailable";
+ ExternalServiceException exception = new ExternalServiceException(message);
+
+ assertThat(exception.getMessage()).isEqualTo(message);
+ assertThat(exception.getErrorCode()).isEqualTo("EXTERNAL_SERVICE_ERROR");
+ }
+
+ @Test
+ void externalServiceExceptionWithCauseShouldHaveCorrectProperties() {
+ String message = "Service unavailable";
+ RuntimeException cause = new RuntimeException("Connection timeout");
+ ExternalServiceException exception = new ExternalServiceException(message, cause);
+
+ assertThat(exception.getMessage()).isEqualTo(message);
+ assertThat(exception.getCause()).isEqualTo(cause);
+ assertThat(exception.getErrorCode()).isEqualTo("EXTERNAL_SERVICE_ERROR");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/domain/model/ProductIdTest.java b/src/test/java/com/zara/similarproducts/domain/model/ProductIdTest.java
new file mode 100644
index 00000000..fe1003e4
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/domain/model/ProductIdTest.java
@@ -0,0 +1,46 @@
+package com.zara.similarproducts.domain.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+class ProductIdTest {
+
+ @Test
+ void shouldCreateValidProductId() {
+ ProductId productId = ProductId.of("123");
+
+ assertThat(productId.value()).isEqualTo("123");
+ }
+
+ @Test
+ void shouldThrowExceptionForNullValue() {
+ assertThatThrownBy(() -> ProductId.of(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("Product ID cannot be null");
+ }
+
+ @Test
+ void shouldThrowExceptionForBlankValue() {
+ assertThatThrownBy(() -> ProductId.of(""))
+ .isInstanceOf(InvalidProductIdException.class);
+
+ assertThatThrownBy(() -> ProductId.of(" "))
+ .isInstanceOf(InvalidProductIdException.class);
+ }
+
+ @Test
+ void shouldThrowExceptionForNonNumericValue() {
+ assertThatThrownBy(() -> ProductId.of("abc"))
+ .isInstanceOf(InvalidProductIdException.class);
+
+ assertThatThrownBy(() -> ProductId.of("12a"))
+ .isInstanceOf(InvalidProductIdException.class);
+ }
+
+ @Test
+ void shouldAcceptNumericValues() {
+ assertThatCode(() -> ProductId.of("123")).doesNotThrowAnyException();
+ assertThatCode(() -> ProductId.of("0")).doesNotThrowAnyException();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/domain/model/ProductTest.java b/src/test/java/com/zara/similarproducts/domain/model/ProductTest.java
new file mode 100644
index 00000000..e54ea921
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/domain/model/ProductTest.java
@@ -0,0 +1,67 @@
+package com.zara.similarproducts.domain.model;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.*;
+
+class ProductTest {
+
+ @Test
+ void shouldCreateValidProduct() {
+ ProductId id = ProductId.of("123");
+ Product product = Product.of(id, "Test Product", BigDecimal.valueOf(99.99), true);
+
+ assertThat(product.id()).isEqualTo(id);
+ assertThat(product.name()).isEqualTo("Test Product");
+ assertThat(product.price()).isEqualTo(BigDecimal.valueOf(99.99));
+ assertThat(product.availability()).isTrue();
+ }
+
+ @Test
+ void shouldThrowExceptionForNullId() {
+ assertThatThrownBy(() -> Product.of(null, "Test", BigDecimal.TEN, true))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("Product ID cannot be null");
+ }
+
+ @Test
+ void shouldThrowExceptionForNullName() {
+ ProductId id = ProductId.of("123");
+ assertThatThrownBy(() -> Product.of(id, null, BigDecimal.TEN, true))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("Product name cannot be null");
+ }
+
+ @Test
+ void shouldThrowExceptionForBlankName() {
+ ProductId id = ProductId.of("123");
+ assertThatThrownBy(() -> Product.of(id, "", BigDecimal.TEN, true))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Product name cannot be blank");
+ }
+
+ @Test
+ void shouldThrowExceptionForNullPrice() {
+ ProductId id = ProductId.of("123");
+ assertThatThrownBy(() -> Product.of(id, "Test", null, true))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("Product price cannot be null");
+ }
+
+ @Test
+ void shouldThrowExceptionForNegativePrice() {
+ ProductId id = ProductId.of("123");
+ assertThatThrownBy(() -> Product.of(id, "Test", BigDecimal.valueOf(-1), true))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Product price cannot be negative");
+ }
+
+ @Test
+ void shouldAcceptZeroPrice() {
+ ProductId id = ProductId.of("123");
+ assertThatCode(() -> Product.of(id, "Test", BigDecimal.ZERO, true))
+ .doesNotThrowAnyException();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/domain/model/SimilarProductsTest.java b/src/test/java/com/zara/similarproducts/domain/model/SimilarProductsTest.java
new file mode 100644
index 00000000..6344dbe7
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/domain/model/SimilarProductsTest.java
@@ -0,0 +1,55 @@
+package com.zara.similarproducts.domain.model;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.*;
+
+class SimilarProductsTest {
+
+ @Test
+ void shouldCreateSimilarProductsWithList() {
+ Product product1 = createProduct("1", "Product 1");
+ Product product2 = createProduct("2", "Product 2");
+ List products = List.of(product1, product2);
+
+ SimilarProducts similarProducts = SimilarProducts.of(products);
+
+ assertThat(similarProducts.products()).hasSize(2);
+ assertThat(similarProducts.size()).isEqualTo(2);
+ assertThat(similarProducts.isEmpty()).isFalse();
+ }
+
+ @Test
+ void shouldCreateEmptySimilarProducts() {
+ SimilarProducts similarProducts = SimilarProducts.empty();
+
+ assertThat(similarProducts.products()).isEmpty();
+ assertThat(similarProducts.size()).isZero();
+ assertThat(similarProducts.isEmpty()).isTrue();
+ }
+
+ @Test
+ void shouldThrowExceptionForNullProductsList() {
+ assertThatThrownBy(() -> SimilarProducts.of(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("Products list cannot be null");
+ }
+
+ @Test
+ void shouldCreateImmutableCopy() {
+ Product product = createProduct("1", "Product 1");
+ List originalList = List.of(product);
+
+ SimilarProducts similarProducts = SimilarProducts.of(originalList);
+
+ assertThat(similarProducts.products()).containsExactlyElementsOf(originalList);
+ assertThat(similarProducts.products().getClass().getName()).contains("Immutable");
+ }
+
+ private Product createProduct(String id, String name) {
+ return Product.of(ProductId.of(id), name, BigDecimal.valueOf(10.0), true);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/domain/service/SimilarProductsDomainServiceTest.java b/src/test/java/com/zara/similarproducts/domain/service/SimilarProductsDomainServiceTest.java
new file mode 100644
index 00000000..10ac91e9
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/domain/service/SimilarProductsDomainServiceTest.java
@@ -0,0 +1,138 @@
+package com.zara.similarproducts.domain.service;
+
+import com.zara.similarproducts.application.port.out.ProductRepository;
+import com.zara.similarproducts.application.port.out.SimilarProductsRepository;
+import com.zara.similarproducts.domain.model.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.math.BigDecimal;
+import java.util.concurrent.TimeoutException;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SimilarProductsDomainServiceTest {
+
+ @Mock
+ private SimilarProductsRepository similarProductsRepository;
+
+ @Mock
+ private ProductRepository productRepository;
+
+ private SimilarProductsDomainService domainService;
+
+ @BeforeEach
+ void setUp() {
+ domainService = new SimilarProductsDomainService(similarProductsRepository, productRepository);
+ }
+
+ @Test
+ void shouldReturnSimilarProductsWhenFound() {
+ ProductId productId = ProductId.of("1");
+ ProductId similarId1 = ProductId.of("2");
+ ProductId similarId2 = ProductId.of("3");
+
+ Product product1 = createProduct(similarId1, "Product 2");
+ Product product2 = createProduct(similarId2, "Product 3");
+
+ when(similarProductsRepository.findSimilarProductIds(productId))
+ .thenReturn(Flux.just(similarId1, similarId2));
+ when(productRepository.findById(similarId1)).thenReturn(Mono.just(product1));
+ when(productRepository.findById(similarId2)).thenReturn(Mono.just(product2));
+
+ StepVerifier.create(domainService.findSimilarProducts(productId))
+ .expectNextMatches(result ->
+ result.size() == 2 &&
+ result.products().contains(product1) &&
+ result.products().contains(product2))
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldThrowProductNotFoundWhenNoSimilarIds() {
+ ProductId productId = ProductId.of("1");
+
+ when(similarProductsRepository.findSimilarProductIds(productId))
+ .thenReturn(Flux.empty());
+
+ StepVerifier.create(domainService.findSimilarProducts(productId))
+ .expectError(ProductNotFoundException.class)
+ .verify();
+ }
+
+ @Test
+ void shouldHandleTimeoutFromSimilarProductsRepository() {
+ ProductId productId = ProductId.of("1");
+
+ when(similarProductsRepository.findSimilarProductIds(productId))
+ .thenReturn(Flux.error(new TimeoutException()));
+
+ StepVerifier.create(domainService.findSimilarProducts(productId))
+ .expectError(ExternalServiceException.class)
+ .verify();
+ }
+
+ @Test
+ void shouldHandleGenericErrorFromSimilarProductsRepository() {
+ ProductId productId = ProductId.of("1");
+
+ when(similarProductsRepository.findSimilarProductIds(productId))
+ .thenReturn(Flux.error(new RuntimeException("Connection error")));
+
+ StepVerifier.create(domainService.findSimilarProducts(productId))
+ .expectError(ExternalServiceException.class)
+ .verify();
+ }
+
+ @Test
+ void shouldSkipProductsWithErrors() {
+ ProductId productId = ProductId.of("1");
+ ProductId similarId1 = ProductId.of("2");
+ ProductId similarId2 = ProductId.of("3");
+
+ Product product1 = createProduct(similarId1, "Product 2");
+
+ when(similarProductsRepository.findSimilarProductIds(productId))
+ .thenReturn(Flux.just(similarId1, similarId2));
+ when(productRepository.findById(similarId1)).thenReturn(Mono.just(product1));
+ when(productRepository.findById(similarId2)).thenReturn(Mono.error(new RuntimeException()));
+
+ StepVerifier.create(domainService.findSimilarProducts(productId))
+ .expectNextMatches(result ->
+ result.size() == 1 &&
+ result.products().contains(product1))
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldSkipProductsWithTimeout() {
+ ProductId productId = ProductId.of("1");
+ ProductId similarId1 = ProductId.of("2");
+ ProductId similarId2 = ProductId.of("3");
+
+ Product product1 = createProduct(similarId1, "Product 2");
+
+ when(similarProductsRepository.findSimilarProductIds(productId))
+ .thenReturn(Flux.just(similarId1, similarId2));
+ when(productRepository.findById(similarId1)).thenReturn(Mono.just(product1));
+ when(productRepository.findById(similarId2)).thenReturn(Mono.error(new TimeoutException()));
+
+ StepVerifier.create(domainService.findSimilarProducts(productId))
+ .expectNextMatches(result ->
+ result.size() == 1 &&
+ result.products().contains(product1))
+ .verifyComplete();
+ }
+
+ private Product createProduct(ProductId id, String name) {
+ return Product.of(id, name, BigDecimal.valueOf(10.0), true);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/controller/SimilarProductsControllerTest.java b/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/controller/SimilarProductsControllerTest.java
new file mode 100644
index 00000000..d180d901
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/controller/SimilarProductsControllerTest.java
@@ -0,0 +1,94 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.controller;
+
+import com.zara.similarproducts.application.port.in.GetSimilarProductsUseCase;
+import com.zara.similarproducts.domain.model.*;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.dto.ProductDetailResponse;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.mapper.SimilarProductsMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import reactor.core.publisher.Mono;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@WebFluxTest(SimilarProductsController.class)
+class SimilarProductsControllerTest {
+
+ @Autowired
+ private WebTestClient webTestClient;
+
+ @MockBean
+ private GetSimilarProductsUseCase getSimilarProductsUseCase;
+
+ @MockBean
+ private SimilarProductsMapper mapper;
+
+ @Test
+ void shouldReturnSimilarProducts() {
+ Product product = createProduct("2", "Similar Product");
+ SimilarProducts similarProducts = SimilarProducts.of(List.of(product));
+ ProductDetailResponse response = new ProductDetailResponse("2", "Similar Product", BigDecimal.valueOf(10.0), true);
+
+ when(getSimilarProductsUseCase.execute(any(ProductId.class)))
+ .thenReturn(Mono.just(similarProducts));
+ when(mapper.toResponse(similarProducts))
+ .thenReturn(List.of(response));
+
+ webTestClient.get()
+ .uri("/product/1/similar")
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().contentType(MediaType.APPLICATION_JSON)
+ .expectBodyList(ProductDetailResponse.class)
+ .hasSize(1)
+ .contains(response);
+ }
+
+ @Test
+ void shouldReturnNotFoundWhenProductNotExists() {
+ when(getSimilarProductsUseCase.execute(any(ProductId.class)))
+ .thenReturn(Mono.error(new ProductNotFoundException(ProductId.of("1"))));
+
+ webTestClient.get()
+ .uri("/product/1/similar")
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ void shouldReturnBadRequestForInvalidProductId() {
+ when(getSimilarProductsUseCase.execute(any(ProductId.class)))
+ .thenThrow(new InvalidProductIdException("abc"));
+
+ webTestClient.get()
+ .uri("/product/abc/similar")
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus().isBadRequest();
+ }
+
+ @Test
+ void shouldReturnServiceUnavailableForExternalServiceError() {
+ when(getSimilarProductsUseCase.execute(any(ProductId.class)))
+ .thenReturn(Mono.error(new ExternalServiceException("Service unavailable")));
+
+ webTestClient.get()
+ .uri("/product/1/similar")
+ .accept(MediaType.APPLICATION_JSON)
+ .exchange()
+ .expectStatus().is5xxServerError();
+ }
+
+ private Product createProduct(String id, String name) {
+ return Product.of(ProductId.of(id), name, BigDecimal.valueOf(10.0), true);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/exception/GlobalExceptionHandlerTest.java
new file mode 100644
index 00000000..42f72ae4
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/exception/GlobalExceptionHandlerTest.java
@@ -0,0 +1,119 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.exception;
+
+import com.zara.similarproducts.domain.model.*;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.dto.ErrorResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.test.StepVerifier;
+
+import java.util.concurrent.TimeoutException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class GlobalExceptionHandlerTest {
+
+ @Mock
+ private ServerWebExchange exchange;
+
+ @Mock
+ private ServerHttpRequest request;
+
+ @Mock
+ private org.springframework.http.server.RequestPath requestPath;
+
+ private GlobalExceptionHandler exceptionHandler;
+
+ @BeforeEach
+ void setUp() {
+ exceptionHandler = new GlobalExceptionHandler();
+ when(exchange.getRequest()).thenReturn(request);
+ when(request.getPath()).thenReturn(requestPath);
+ when(requestPath.value()).thenReturn("/product/1/similar");
+ }
+
+ @Test
+ void shouldHandleProductNotFoundException() {
+ ProductId productId = ProductId.of("123");
+ ProductNotFoundException exception = new ProductNotFoundException(productId);
+
+ StepVerifier.create(exceptionHandler.handleProductNotFound(exception, exchange))
+ .expectNextMatches(response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ ErrorResponse body = response.getBody();
+ assertThat(body.error()).isEqualTo("PRODUCT_NOT_FOUND");
+ assertThat(body.message()).contains("Product not found: 123");
+ assertThat(body.path()).isEqualTo("/product/1/similar");
+ return true;
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldHandleInvalidProductIdException() {
+ InvalidProductIdException exception = new InvalidProductIdException("abc");
+
+ StepVerifier.create(exceptionHandler.handleInvalidProductId(exception, exchange))
+ .expectNextMatches(response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ ErrorResponse body = response.getBody();
+ assertThat(body.error()).isEqualTo("INVALID_PRODUCT_ID");
+ assertThat(body.message()).contains("Invalid product ID format: abc");
+ return true;
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldHandleExternalServiceException() {
+ ExternalServiceException exception = new ExternalServiceException("Service unavailable");
+
+ StepVerifier.create(exceptionHandler.handleExternalService(exception, exchange))
+ .expectNextMatches(response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ ErrorResponse body = response.getBody();
+ assertThat(body.error()).isEqualTo("EXTERNAL_SERVICE_ERROR");
+ assertThat(body.message()).isEqualTo("Service temporarily unavailable");
+ return true;
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldHandleTimeoutException() {
+ TimeoutException exception = new TimeoutException("Request timeout");
+
+ StepVerifier.create(exceptionHandler.handleTimeout(exception, exchange))
+ .expectNextMatches(response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT);
+ ErrorResponse body = response.getBody();
+ assertThat(body.error()).isEqualTo("REQUEST_TIMEOUT");
+ assertThat(body.message()).isEqualTo("Request timeout");
+ return true;
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldHandleGenericException() {
+ RuntimeException exception = new RuntimeException("Unexpected error");
+
+ StepVerifier.create(exceptionHandler.handleGenericException(exception, exchange))
+ .expectNextMatches(response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ ErrorResponse body = response.getBody();
+ assertThat(body.error()).isEqualTo("INTERNAL_SERVER_ERROR");
+ assertThat(body.message()).isEqualTo("Internal server error");
+ return true;
+ })
+ .verifyComplete();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/mapper/SimilarProductsMapperTest.java b/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/mapper/SimilarProductsMapperTest.java
new file mode 100644
index 00000000..e178464b
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/adapter/in/rest/mapper/SimilarProductsMapperTest.java
@@ -0,0 +1,59 @@
+package com.zara.similarproducts.infrastructure.adapter.in.rest.mapper;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.domain.model.ProductId;
+import com.zara.similarproducts.domain.model.SimilarProducts;
+import com.zara.similarproducts.infrastructure.adapter.in.rest.dto.ProductDetailResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SimilarProductsMapperTest {
+
+ private SimilarProductsMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new SimilarProductsMapper();
+ }
+
+ @Test
+ void shouldMapSimilarProductsToResponse() {
+ Product product1 = createProduct("1", "Product 1", BigDecimal.valueOf(99.99), true);
+ Product product2 = createProduct("2", "Product 2", BigDecimal.valueOf(149.99), false);
+ SimilarProducts similarProducts = SimilarProducts.of(List.of(product1, product2));
+
+ List response = mapper.toResponse(similarProducts);
+
+ assertThat(response).hasSize(2);
+
+ ProductDetailResponse response1 = response.get(0);
+ assertThat(response1.id()).isEqualTo("1");
+ assertThat(response1.name()).isEqualTo("Product 1");
+ assertThat(response1.price()).isEqualTo(BigDecimal.valueOf(99.99));
+ assertThat(response1.availability()).isTrue();
+
+ ProductDetailResponse response2 = response.get(1);
+ assertThat(response2.id()).isEqualTo("2");
+ assertThat(response2.name()).isEqualTo("Product 2");
+ assertThat(response2.price()).isEqualTo(BigDecimal.valueOf(149.99));
+ assertThat(response2.availability()).isFalse();
+ }
+
+ @Test
+ void shouldMapEmptySimilarProducts() {
+ SimilarProducts similarProducts = SimilarProducts.empty();
+
+ List response = mapper.toResponse(similarProducts);
+
+ assertThat(response).isEmpty();
+ }
+
+ private Product createProduct(String id, String name, BigDecimal price, boolean availability) {
+ return Product.of(ProductId.of(id), name, price, availability);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductMapperTest.java b/src/test/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductMapperTest.java
new file mode 100644
index 00000000..e0190d9e
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/adapter/out/external/ExternalProductMapperTest.java
@@ -0,0 +1,30 @@
+package com.zara.similarproducts.infrastructure.adapter.out.external;
+
+import com.zara.similarproducts.domain.model.Product;
+import com.zara.similarproducts.infrastructure.adapter.out.external.dto.ExternalProductResponse;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ExternalProductMapperTest {
+
+ private final ExternalProductMapper mapper = new ExternalProductMapper();
+
+ @Test
+ void shouldMapExternalProductResponseToDomain() {
+ // Given
+ ExternalProductResponse response = new ExternalProductResponse(
+ "1", "Test Product", new BigDecimal("99.99"), true);
+
+ // When
+ Product product = mapper.toDomain(response);
+
+ // Then
+ assertThat(product.id().value()).isEqualTo("1");
+ assertThat(product.name()).isEqualTo("Test Product");
+ assertThat(product.price()).isEqualByComparingTo("99.99");
+ assertThat(product.availability()).isTrue();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/adapter/out/external/dto/ExternalProductResponseTest.java b/src/test/java/com/zara/similarproducts/infrastructure/adapter/out/external/dto/ExternalProductResponseTest.java
new file mode 100644
index 00000000..5e679e67
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/adapter/out/external/dto/ExternalProductResponseTest.java
@@ -0,0 +1,28 @@
+package com.zara.similarproducts.infrastructure.adapter.out.external.dto;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ExternalProductResponseTest {
+
+ @Test
+ void shouldCreateExternalProductResponse() {
+ // Given
+ String id = "1";
+ String name = "Test Product";
+ BigDecimal price =new BigDecimal( 99.99);
+ Boolean availability = true;
+
+ // When
+ ExternalProductResponse response = new ExternalProductResponse(id, name, price, availability);
+
+ // Then
+ assertThat(response.id()).isEqualTo(id);
+ assertThat(response.name()).isEqualTo(name);
+ assertThat(response.price()).isEqualTo(price);
+ assertThat(response.availability()).isEqualTo(availability);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/config/DomainConfigTest.java b/src/test/java/com/zara/similarproducts/infrastructure/config/DomainConfigTest.java
new file mode 100644
index 00000000..59b1a4df
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/config/DomainConfigTest.java
@@ -0,0 +1,34 @@
+package com.zara.similarproducts.infrastructure.config;
+
+import com.zara.similarproducts.application.port.out.ProductRepository;
+import com.zara.similarproducts.application.port.out.SimilarProductsRepository;
+import com.zara.similarproducts.domain.service.SimilarProductsDomainService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(MockitoExtension.class)
+class DomainConfigTest {
+
+ @Mock
+ private SimilarProductsRepository similarProductsRepository;
+
+ @Mock
+ private ProductRepository productRepository;
+
+ @Test
+ void shouldCreateSimilarProductsDomainService() {
+ // Given
+ DomainConfig config = new DomainConfig();
+
+ // When
+ SimilarProductsDomainService service = config.similarProductsDomainService(
+ similarProductsRepository, productRepository);
+
+ // Then
+ assertThat(service).isNotNull();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/zara/similarproducts/infrastructure/config/WebClientConfigTest.java b/src/test/java/com/zara/similarproducts/infrastructure/config/WebClientConfigTest.java
new file mode 100644
index 00000000..771d41a1
--- /dev/null
+++ b/src/test/java/com/zara/similarproducts/infrastructure/config/WebClientConfigTest.java
@@ -0,0 +1,35 @@
+package com.zara.similarproducts.infrastructure.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WebClientConfigTest {
+
+ @Test
+ void shouldCreateWebClientWithDefaultBaseUrl() {
+ // Given
+ WebClientConfig config = new WebClientConfig();
+ String baseUrl = "http://localhost:3001";
+
+ // When
+ WebClient webClient = config.webClient(baseUrl);
+
+ // Then
+ assertThat(webClient).isNotNull();
+ }
+
+ @Test
+ void shouldCreateWebClientWithCustomBaseUrl() {
+ // Given
+ WebClientConfig config = new WebClientConfig();
+ String customBaseUrl = "http://custom-api:8080";
+
+ // When
+ WebClient webClient = config.webClient(customBaseUrl);
+
+ // Then
+ assertThat(webClient).isNotNull();
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
new file mode 100644
index 00000000..8f954c20
--- /dev/null
+++ b/src/test/resources/application-test.yml
@@ -0,0 +1,14 @@
+logging:
+ level:
+ com.zara.similarproducts: DEBUG
+ org.springframework.web.reactive.function.client: DEBUG
+
+app:
+ external-api:
+ base-url: http://localhost:3001
+ timeout:
+ similar-ids: 1s
+ product-detail: 2s
+ overall: 5s
+ concurrency:
+ max-parallel: 5
\ No newline at end of file