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