diff --git a/.env.local b/.env.local
new file mode 100644
index 00000000..223459b4
--- /dev/null
+++ b/.env.local
@@ -0,0 +1,2 @@
+SERVER_PORT=5000
+SPRING_PROFILES_ACTIVE=local
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..3b41682a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..667aaef0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/.sdkmanrc b/.sdkmanrc
new file mode 100644
index 00000000..00693677
--- /dev/null
+++ b/.sdkmanrc
@@ -0,0 +1,4 @@
+# Enable auto-env through the sdkman_auto_env config
+# Add key=value pairs of SDKs to use below
+java=21.0.7-tem
+maven=3.9.6
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..1b12d42d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+FROM eclipse-temurin:21.0.7_6-jre-alpine
+
+ARG PROJECT_VERSION="0.0.1-SNAPSHOT"
+ENV PROJECT_VERSION="$PROJECT_VERSION"
+ENV MAIN_DIR="/app"
+
+RUN addgroup -S backprod && adduser -S backprod -G backprod && \
+ mkdir -p "/app" && \
+ chown -R backprod:backprod "/app" && \
+ chgrp -R backprod $JAVA_HOME && \
+ chmod ugo+rwx -R /var/log && \
+ mkdir /var/log/backprod && \
+ chmod ugo+rwx -R /var/log/backprod && \
+ chmod -R g+rw $JAVA_HOME
+
+ADD bootstrap/target/bootstrap-${PROJECT_VERSION}.jar /app/app.jar
+
+WORKDIR $MAIN_DIR
+
+ADD start.sh /app/start.sh
+RUN chmod +x /app/start.sh
+
+USER backprod
+
+ENTRYPOINT [ "sh", "/app/start.sh" ]
\ No newline at end of file
diff --git a/adapters/pom.xml b/adapters/pom.xml
new file mode 100644
index 00000000..b1b84318
--- /dev/null
+++ b/adapters/pom.xml
@@ -0,0 +1,132 @@
+
+
+ 4.0.0
+
+
+ dev.molaya.tests
+ backprods
+ 0.0.1-SNAPSHOT
+
+
+ adapters
+ Adapters Module
+
+
+ dev.molaya.tests.adapters.out.client.gen.openapi
+ dev.molaya.tests.adapters.in.rest.gen.openapi
+ 2.8.6
+ GenApi
+ **/auth/**,**/gen/**
+
+
+
+
+ ${project.groupId}
+ application
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc-openapi-starter-webmvc-ui.version}
+
+
+
+ io.swagger.core.v3
+ swagger-annotations
+
+
+ jakarta.validation
+ jakarta.validation-api
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+ org.openapitools
+ jackson-databind-nullable
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux-test
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+ org.openapitools
+ openapi-generator-maven-plugin
+ ${openapi-generator-maven-plugin.version}
+
+
+
+ client-interface-generator
+ generate
+
+ ${project.basedir}/src/main/resources/existingApis.yaml
+ java
+ webclient
+ ${openapi.out.client}
+ ${openapi.out.client}.dto
+ ${openapi-generator-maven-plugin.apiNameSuffix}
+ false
+ false
+
+ true
+ true
+
+
+
+
+
+
+ rest-interface-generator
+ generate
+
+ ${project.basedir}/src/main/resources/similarProducts.yaml
+ spring
+ ${openapi.in.rest}
+ ${openapi.in.rest}.dto
+ false
+ ${openapi-generator-maven-plugin.apiNameSuffix}
+
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapper.java b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapper.java
new file mode 100644
index 00000000..8a924996
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapper.java
@@ -0,0 +1,29 @@
+package dev.molaya.tests.adapters.in.rest;
+
+import dev.molaya.tests.adapters.in.rest.gen.openapi.dto.ProductDetail;
+import dev.molaya.tests.application.in.dto.GetSimilarProductsInput;
+import dev.molaya.tests.domain.Product;
+import dev.molaya.tests.domain.exceptions.BadParametersException;
+import java.util.Optional;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.springframework.http.ResponseEntity;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+@Mapper
+public interface RestSimilarProductsMapper {
+
+ @Mapping(target = "availability", source = "available")
+ ProductDetail toRestProductDetail(Product product);
+
+ default Mono>> wrapAsOkResponse(Flux dto) {
+ return Mono.just(ResponseEntity.ok(dto));
+ }
+
+ default GetSimilarProductsInput toGetSimilarProductsInput(String productId) {
+ return Optional.ofNullable(productId)
+ .map(GetSimilarProductsInput::new)
+ .orElseThrow(() -> new BadParametersException("Product id should not be null"));
+ }
+}
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/SimilarProductsController.java b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/SimilarProductsController.java
new file mode 100644
index 00000000..1911bb8e
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/SimilarProductsController.java
@@ -0,0 +1,39 @@
+package dev.molaya.tests.adapters.in.rest;
+
+import dev.molaya.tests.adapters.in.rest.gen.openapi.ProductGenApi;
+import dev.molaya.tests.adapters.in.rest.gen.openapi.dto.ProductDetail;
+import dev.molaya.tests.application.in.GetSimilarProductsUseCase;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import jakarta.validation.constraints.NotNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.RestController;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class SimilarProductsController implements ProductGenApi {
+ private final GetSimilarProductsUseCase useCase;
+ private final RestSimilarProductsMapper mapper;
+
+ @Override
+ @GetMapping(ProductGenApi.PATH_GET_PRODUCT_SIMILAR)
+ public Mono>> getProductSimilar(
+ @NotNull @Parameter(name = "productId", description = "", required = true, in = ParameterIn.PATH)
+ @PathVariable("productId")
+ String productId,
+ @Parameter(hidden = true) final ServerWebExchange exchange) {
+ log.info("Getting similar products for {}", productId);
+ final var input = mapper.toGetSimilarProductsInput(productId);
+ return useCase.getSimilarProducts(input)
+ .map(mapper::toRestProductDetail)
+ .as(mapper::wrapAsOkResponse);
+ }
+}
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdvice.java b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdvice.java
new file mode 100644
index 00000000..d0f1a4fe
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdvice.java
@@ -0,0 +1,33 @@
+package dev.molaya.tests.adapters.in.rest.advice;
+
+import dev.molaya.tests.adapters.in.rest.advice.dto.ErrorType;
+import dev.molaya.tests.domain.exceptions.IntegrationException;
+import java.util.Optional;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice
+public class SimilarProductsControllerAdvice {
+
+ @ExceptionHandler({Exception.class})
+ public ResponseEntity handleException(Exception ex) {
+ log.info("Unexpected error occurred: {} - trace: ", ex.getMessage(), ex);
+ return ErrorType.UNKNOWN.getErrorResponseEntity(ex.getMessage());
+ }
+
+ @ExceptionHandler({IntegrationException.class})
+ public ResponseEntity handleIntegrationException(IntegrationException exception) {
+ final var message = Optional.ofNullable(exception.getCause())
+ .map(Throwable::getMessage)
+ .orElse("not provided");
+ log.info(
+ "Integration error occurred: {} - internal exception [{}] - trace: ",
+ exception.getMessage(),
+ message,
+ exception);
+ return ErrorType.EXTERNAL_SERVICE.getErrorResponseEntity(exception.getMessage());
+ }
+}
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/dto/ErrorType.java b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/dto/ErrorType.java
new file mode 100644
index 00000000..8acbef7b
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/dto/ErrorType.java
@@ -0,0 +1,29 @@
+package dev.molaya.tests.adapters.in.rest.advice.dto;
+
+import lombok.Builder;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+@RequiredArgsConstructor
+public enum ErrorType {
+ INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "MOLAR-400", "Invalid parameter provided"),
+ UNKNOWN(HttpStatus.INTERNAL_SERVER_ERROR, "MOLAR-500", "Unknown error occurred"),
+ EXTERNAL_SERVICE(HttpStatus.CONFLICT, "MOLAR-409", "Integration with external service failed");
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+
+ public ResponseEntity<@NonNull ApiError> getErrorResponseEntity(String customMessage) {
+ return ResponseEntity.status(httpStatus.value())
+ .body(ApiError.builder()
+ .code(code)
+ .message(message)
+ .detail(customMessage)
+ .build());
+ }
+
+ @Builder
+ public record ApiError(String code, String message, String detail) {}
+}
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapter.java b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapter.java
new file mode 100644
index 00000000..1e2ccbf8
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapter.java
@@ -0,0 +1,40 @@
+package dev.molaya.tests.adapters.out.client;
+
+import dev.molaya.tests.adapters.out.client.gen.openapi.DefaultGenApi;
+import dev.molaya.tests.application.out.ProductRepositoryPort;
+import dev.molaya.tests.application.out.dto.ProductCommand;
+import dev.molaya.tests.domain.Product;
+import jakarta.validation.constraints.NotNull;
+import java.util.Collections;
+import java.util.Set;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Mono;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ClientProductRepositoryAdapter implements ProductRepositoryPort {
+ private final DefaultGenApi defaultApi;
+ private final ClientSimilarProductsMapper mapper;
+
+ @Override
+ public Mono<@NonNull Product> getProductDetail(@NotNull ProductCommand productCommand) {
+ final var id = productCommand.id();
+ return Mono.defer(() -> defaultApi.getProductProductId(id).map(mapper::toDomainProduct))
+ .doOnSuccess(resp -> log.info("Fetched product detail: {}", resp))
+ .doOnError(e -> log.warn("Failed to fetch product detail for product: {}", id))
+ .onErrorResume(e -> Mono.empty());
+ }
+
+ @Override
+ public Mono<@NonNull Set> getSimilarProductIds(@NotNull ProductCommand productCommand) {
+ final var id = productCommand.id();
+ return Mono.defer(() -> defaultApi.getProductSimilarids(id))
+ .doOnSuccess(resp -> log.info("Fetched similar product ids for product: {} {}", id, resp.size()))
+ .doOnError(e -> log.warn("Failed to fetch similar product ids for product: {}", id))
+ .onErrorResume(e -> Mono.just(Collections.emptySet()));
+ }
+}
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapper.java b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapper.java
new file mode 100644
index 00000000..3c408a8f
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapper.java
@@ -0,0 +1,13 @@
+package dev.molaya.tests.adapters.out.client;
+
+import dev.molaya.tests.adapters.out.client.gen.openapi.dto.ProductDetail;
+import dev.molaya.tests.domain.Product;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+@Mapper
+public interface ClientSimilarProductsMapper {
+
+ @Mapping(target = "available", source = "availability")
+ Product toDomainProduct(ProductDetail source);
+}
diff --git a/adapters/src/main/java/dev/molaya/tests/adapters/out/client/config/ClientWebClientConfiguration.java b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/config/ClientWebClientConfiguration.java
new file mode 100644
index 00000000..a4b3b7b9
--- /dev/null
+++ b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/config/ClientWebClientConfiguration.java
@@ -0,0 +1,13 @@
+package dev.molaya.tests.adapters.out.client.config;
+
+import dev.molaya.tests.adapters.out.client.gen.openapi.DefaultGenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ClientWebClientConfiguration {
+ @Bean
+ public DefaultGenApi defaultGenApi() {
+ return new DefaultGenApi();
+ }
+}
diff --git a/existingApis.yaml b/adapters/src/main/resources/existingApis.yaml
similarity index 74%
rename from existingApis.yaml
rename to adapters/src/main/resources/existingApis.yaml
index cf7805e8..22c6b748 100644
--- a/existingApis.yaml
+++ b/adapters/src/main/resources/existingApis.yaml
@@ -1,11 +1,11 @@
openapi: 3.0.0
info:
title: existingApis
- version: '1.0'
+ version: "1.0"
servers:
- - url: 'http://localhost:3001'
+ - url: "http://localhost:3001"
paths:
- '/product/{productId}/similarids':
+ "/product/{productId}/similarids":
parameters:
- schema:
type: string
@@ -17,13 +17,13 @@ paths:
summary: Gets the ids of the similar products
description: Returns the similar products to a given one ordered by similarity
responses:
- '200':
+ "200":
description: OK
content:
application/json:
schema:
- $ref: '#/components/schemas/SimilarProducts'
- '/product/{productId}':
+ $ref: "#/components/schemas/SimilarProducts"
+ "/product/{productId}":
parameters:
- schema:
type: string
@@ -35,26 +35,26 @@ paths:
summary: Gets a product detail
description: Returns the product detail for a given productId
responses:
- '200':
+ "200":
description: OK
content:
application/json:
schema:
- $ref: '#/components/schemas/ProductDetail'
- '404':
+ $ref: "#/components/schemas/ProductDetail"
+ "404":
description: Product Not found
components:
schemas:
SimilarProducts:
type: array
- description: 'List of similar product Ids to a given one ordered by similarity'
+ description: "List of similar product Ids to a given one ordered by similarity"
minItems: 0
uniqueItems: true
items:
type: string
- example: ["1","2","3"]
+ example: ["1", "2", "3"]
ProductDetail:
- description: 'Product detail'
+ description: "Product detail"
type: object
properties:
id:
@@ -71,4 +71,4 @@ components:
- id
- name
- price
- - availability
\ No newline at end of file
+ - availability
diff --git a/similarProducts.yaml b/adapters/src/main/resources/similarProducts.yaml
similarity index 69%
rename from similarProducts.yaml
rename to adapters/src/main/resources/similarProducts.yaml
index b8310c05..98bb4b67 100644
--- a/similarProducts.yaml
+++ b/adapters/src/main/resources/similarProducts.yaml
@@ -1,11 +1,11 @@
openapi: 3.0.0
info:
title: SimilarProducts
- version: '1.0'
+ version: "1.0"
servers:
- - url: 'http://localhost:5000'
+ - url: "http://localhost:5000"
paths:
- '/product/{productId}/similar':
+ "/product/{productId}/similar":
parameters:
- schema:
type: string
@@ -16,25 +16,25 @@ paths:
operationId: get-product-similar
summary: Similar products
responses:
- '200':
+ "200":
description: OK
content:
application/json:
schema:
- $ref: '#/components/schemas/SimilarProducts'
- '404':
+ $ref: "#/components/schemas/SimilarProducts"
+ "404":
description: Product Not found
components:
schemas:
SimilarProducts:
type: array
- description: 'List of similar products to a given one ordered by similarity'
+ description: "List of similar products to a given one ordered by similarity"
minItems: 0
uniqueItems: true
items:
- $ref: '#/components/schemas/ProductDetail'
+ $ref: "#/components/schemas/ProductDetail"
ProductDetail:
- description: 'Product detail'
+ description: "Product detail"
type: object
properties:
id:
@@ -51,4 +51,4 @@ components:
- id
- name
- price
- - availability
\ No newline at end of file
+ - availability
diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/AdapterTestApplication.java b/adapters/src/test/java/dev/molaya/tests/adapters/AdapterTestApplication.java
new file mode 100644
index 00000000..99baacd0
--- /dev/null
+++ b/adapters/src/test/java/dev/molaya/tests/adapters/AdapterTestApplication.java
@@ -0,0 +1,6 @@
+package dev.molaya.tests.adapters;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class AdapterTestApplication {}
diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java b/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java
new file mode 100644
index 00000000..73a0de1e
--- /dev/null
+++ b/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java
@@ -0,0 +1,40 @@
+package dev.molaya.tests.adapters;
+
+import dev.molaya.tests.adapters.in.rest.gen.openapi.dto.ProductDetail;
+import dev.molaya.tests.domain.Product;
+import java.math.BigDecimal;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class Stubs {
+ public static Product product() {
+ return new Product("A", "Product 1", BigDecimal.valueOf(10.0), true);
+ }
+
+ public static Product product(String id) {
+ return product().toBuilder().id(id).build();
+ }
+
+ public static ProductDetail productDetail() {
+ final var detail = new ProductDetail();
+ detail.setId("A");
+ detail.setName("Product 1");
+ detail.setPrice(BigDecimal.valueOf(10.0));
+ detail.setAvailability(Boolean.TRUE);
+ return detail;
+ }
+
+ public static dev.molaya.tests.adapters.out.client.gen.openapi.dto.ProductDetail productDetailOUT() {
+ final var detail = new dev.molaya.tests.adapters.out.client.gen.openapi.dto.ProductDetail();
+ detail.setId("A");
+ detail.setName("Product 1");
+ detail.setPrice(BigDecimal.valueOf(10.0));
+ detail.setAvailability(Boolean.TRUE);
+ return detail;
+ }
+
+ public static Set similarIds() {
+ return Stream.of("A", "B", "C").collect(Collectors.toSet());
+ }
+}
diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapperTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapperTest.java
new file mode 100644
index 00000000..edd241a0
--- /dev/null
+++ b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapperTest.java
@@ -0,0 +1,80 @@
+package dev.molaya.tests.adapters.in.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import dev.molaya.tests.adapters.Stubs;
+import dev.molaya.tests.domain.Product;
+import dev.molaya.tests.domain.exceptions.BadParametersException;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mapstruct.factory.Mappers;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatusCode;
+import reactor.core.publisher.Flux;
+
+@ExtendWith(MockitoExtension.class)
+class RestSimilarProductsMapperTest {
+ private final RestSimilarProductsMapper mapper = Mappers.getMapper(RestSimilarProductsMapper.class);
+
+ @Test
+ void toRestProductDetail() {
+ final var product = Product.builder()
+ .id("1")
+ .name("Product 1")
+ .price(BigDecimal.valueOf(100.0))
+ .available(true)
+ .build();
+ final var productDetail = mapper.toRestProductDetail(product);
+ assertNotNull(productDetail);
+ assertEquals(product.available(), productDetail.getAvailability());
+ assertEquals(product.id(), productDetail.getId());
+ assertEquals(product.name(), productDetail.getName());
+ assertEquals(product.price(), productDetail.getPrice());
+ }
+
+ @Test
+ void toRestProductDetail_NotAvailable() {
+ final var product = Product.builder()
+ .id("2")
+ .name("Product 2")
+ .price(BigDecimal.valueOf(200.0))
+ .available(false)
+ .build();
+ final var productDetail = mapper.toRestProductDetail(product);
+ assertNotNull(productDetail);
+ assertEquals(product.available(), productDetail.getAvailability());
+ }
+
+ @Test
+ void toRestProductDetail_NullProduct() {
+ final var productDetail = mapper.toRestProductDetail(null);
+ assertNull(productDetail);
+ }
+
+ @Test
+ void wrapResponseEntityOk() {
+ final var products = Flux.just(Stubs.productDetail());
+ final var responseEntity = mapper.wrapAsOkResponse(products).block();
+ assertNotNull(responseEntity);
+ assertEquals(HttpStatusCode.valueOf(200), responseEntity.getStatusCode());
+ }
+
+ @Test
+ void toGetSimilarProductsInput() {
+ final var productId = "123";
+ final var input = mapper.toGetSimilarProductsInput(productId);
+ assertNotNull(input);
+ assertEquals(productId, input.productId());
+ }
+
+ @Test
+ void toGetSimilarProductsInput_OnNullThrowsBadParametersException() {
+ final var exception = assertThrows(BadParametersException.class, () -> mapper.toGetSimilarProductsInput(null));
+ assertNotNull(exception);
+ assertEquals("Product id should not be null", exception.getMessage());
+ }
+}
diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerTest.java
new file mode 100644
index 00000000..2b8b5ce3
--- /dev/null
+++ b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerTest.java
@@ -0,0 +1,95 @@
+package dev.molaya.tests.adapters.in.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.when;
+
+import dev.molaya.tests.adapters.Stubs;
+import dev.molaya.tests.application.in.GetSimilarProductsUseCase;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+
+@ExtendWith(MockitoExtension.class)
+class SimilarProductsControllerTest {
+ @Mock
+ private GetSimilarProductsUseCase useCase;
+
+ @Mock
+ private RestSimilarProductsMapper mapper;
+
+ @Mock
+ private ServerWebExchange exchange;
+
+ @InjectMocks
+ private SimilarProductsController controller;
+
+ @Nested
+ class HappyPath {
+ @ParameterizedTest()
+ @ValueSource(strings = {"A", "B", "C"})
+ void returnsSimilarProducts(final String productId) {
+ final var products = Flux.just(Stubs.product(productId), Stubs.product("B"));
+ when(useCase.getSimilarProducts(any())).thenReturn(products);
+ when(mapper.toGetSimilarProductsInput(any())).thenCallRealMethod();
+ when(mapper.wrapAsOkResponse(any())).thenCallRealMethod();
+
+ final var resultMono = controller.getProductSimilar(productId, exchange);
+ final var response = resultMono.block();
+ assertNotNull(response);
+ assertEquals(HttpStatusCode.valueOf(200), response.getStatusCode());
+ final var details = response.getBody();
+ assertNotNull(details);
+ }
+ }
+
+ @Nested
+ class Exceptions {
+
+ public static final String DEFAULT_PRODUCT_ID = "A";
+
+ @Test
+ void returnsEmptyWhenNoProducts() {
+ when(useCase.getSimilarProducts(any())).thenReturn(Flux.empty());
+ when(mapper.toGetSimilarProductsInput(any())).thenCallRealMethod();
+ when(mapper.wrapAsOkResponse(any())).thenCallRealMethod();
+
+ final var resultMono = controller.getProductSimilar(DEFAULT_PRODUCT_ID, exchange);
+ final var response = resultMono.block();
+ assertNotNull(response);
+ assertEquals(ResponseEntity.ok(Flux.empty()).getStatusCode(), response.getStatusCode());
+ }
+
+ @Test
+ void propagatesErrorFromUseCase() {
+ when(useCase.getSimilarProducts(any())).thenReturn(Flux.error(new RuntimeException("fail")));
+ when(mapper.toGetSimilarProductsInput(any())).thenCallRealMethod();
+ when(mapper.wrapAsOkResponse(any())).thenCallRealMethod();
+
+ final var resultMono = controller.getProductSimilar(DEFAULT_PRODUCT_ID, exchange);
+ final var responseEntity = resultMono.block();
+ assertNotNull(responseEntity);
+ final var bodyFlux = responseEntity.getBody();
+ assertNotNull(bodyFlux);
+ assertThrows(RuntimeException.class, bodyFlux::blockFirst);
+ }
+
+ @Test
+ void propagatesErrorFromUseCaseException() {
+ when(useCase.getSimilarProducts(any())).thenThrow(new RuntimeException("fail"));
+
+ assertThrows(RuntimeException.class, () -> controller.getProductSimilar(DEFAULT_PRODUCT_ID, exchange));
+ }
+ }
+}
diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerWebFluxTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerWebFluxTest.java
new file mode 100644
index 00000000..4ddae07d
--- /dev/null
+++ b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerWebFluxTest.java
@@ -0,0 +1,83 @@
+package dev.molaya.tests.adapters.in.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.when;
+
+import dev.molaya.tests.adapters.AdapterTestApplication;
+import dev.molaya.tests.adapters.Stubs;
+import dev.molaya.tests.adapters.in.rest.gen.openapi.dto.ProductDetail;
+import dev.molaya.tests.application.in.GetSimilarProductsUseCase;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mapstruct.factory.Mappers;
+import org.mockito.stubbing.Answer;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webflux.test.autoconfigure.WebFluxTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import reactor.core.publisher.Flux;
+
+@WebFluxTest(controllers = SimilarProductsController.class)
+@Import(AdapterTestApplication.class)
+class SimilarProductsControllerWebFluxTest {
+ @Autowired
+ private WebTestClient webTestClient;
+
+ @MockitoBean
+ private GetSimilarProductsUseCase useCase;
+
+ @MockitoBean
+ private RestSimilarProductsMapper mapper;
+
+ static final RestSimilarProductsMapper REAL_MAPPER = Mappers.getMapper(RestSimilarProductsMapper.class);
+
+ @BeforeEach
+ void setup() {
+ when(mapper.toRestProductDetail(any())).thenAnswer(callRealMapper());
+ }
+
+ @Nested
+ class HappyPath {
+ @ParameterizedTest
+ @ValueSource(strings = {"A", "B"})
+ void returnsSimilarProducts(final String productId) {
+ final var products = Flux.just(Stubs.product(productId), Stubs.product("B"));
+ when(useCase.getSimilarProducts(any())).thenReturn(products);
+ when(mapper.toRestProductDetail(any())).thenAnswer(callRealMapper());
+ final var response = webTestClient
+ .get()
+ .uri("/product/{id}/similar", productId)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .returnResult(ProductDetail.class);
+ assertNotNull(response.getResponseBody());
+ }
+ }
+
+ private static Answer