From 5dc678c3e3709efa34b669f6ad8197951654f4af Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 12:12:34 +0100 Subject: [PATCH 1/9] chore: initial setup structure and design --- .env.local | 2 + .gitattributes | 2 + .gitignore | 33 ++ .sdkmanrc | 4 + Dockerfile | 25 ++ adapters/pom.xml | 132 ++++++++ .../in/rest/RestSimilarProductsMapper.java | 29 ++ .../in/rest/advice/dto/ErrorType.java | 29 ++ .../client/ClientSimilarProductsMapper.java | 13 + .../src/main/resources/existingApis.yaml | 26 +- .../src/main/resources/similarProducts.yaml | 20 +- .../adapters/AdapterTestApplication.java | 6 + application/pom.xml | 57 ++++ .../in/GetSimilarProductsUseCase.java | 12 + .../in/dto/GetSimilarProductsInput.java | 7 + .../out/ProductRepositoryPort.java | 14 + .../application/out/dto/ProductCommand.java | 7 + .../service/ServiceSimilarProductsMapper.java | 22 ++ bootstrap/pom.xml | 46 +++ .../molaya/tests/BackProductsApplication.java | 11 + .../src/main/resources/application-local.yaml | 5 + bootstrap/src/main/resources/application.yaml | 21 ++ bootstrap/src/test/java/README.md | 4 + .../src/test/resources/features/README.md | 1 + docker-compose.yaml | 1 + domain/pom.xml | 36 +++ .../java/dev/molaya/tests/domain/Product.java | 7 + .../exceptions/BadParametersException.java | 7 + .../exceptions/IntegrationException.java | 7 + mvnw | 295 +++++++++++++++++ mvnw.cmd | 189 +++++++++++ pom.xml | 304 ++++++++++++++++++ readme.md | 124 ++++++- start.sh | 7 + 34 files changed, 1480 insertions(+), 25 deletions(-) create mode 100644 .env.local create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .sdkmanrc create mode 100644 Dockerfile create mode 100644 adapters/pom.xml create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapper.java create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/dto/ErrorType.java create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapper.java rename existingApis.yaml => adapters/src/main/resources/existingApis.yaml (74%) rename similarProducts.yaml => adapters/src/main/resources/similarProducts.yaml (69%) create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/AdapterTestApplication.java create mode 100644 application/pom.xml create mode 100644 application/src/main/java/dev/molaya/tests/application/in/GetSimilarProductsUseCase.java create mode 100644 application/src/main/java/dev/molaya/tests/application/in/dto/GetSimilarProductsInput.java create mode 100644 application/src/main/java/dev/molaya/tests/application/out/ProductRepositoryPort.java create mode 100644 application/src/main/java/dev/molaya/tests/application/out/dto/ProductCommand.java create mode 100644 application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java create mode 100644 bootstrap/pom.xml create mode 100644 bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java create mode 100644 bootstrap/src/main/resources/application-local.yaml create mode 100644 bootstrap/src/main/resources/application.yaml create mode 100644 bootstrap/src/test/java/README.md create mode 100644 bootstrap/src/test/resources/features/README.md create mode 100644 domain/pom.xml create mode 100644 domain/src/main/java/dev/molaya/tests/domain/Product.java create mode 100644 domain/src/main/java/dev/molaya/tests/domain/exceptions/BadParametersException.java create mode 100644 domain/src/main/java/dev/molaya/tests/domain/exceptions/IntegrationException.java create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 start.sh 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..8e83a491 --- /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 + dev.molaya.tests.adapters.in.rest + 2.8.6 + GenApi + **/*${openapi-generator-maven-plugin.apiNameSuffix}.java + + + + + ${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..37d6addc --- /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.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/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/ClientSimilarProductsMapper.java b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapper.java new file mode 100644 index 00000000..4b896a2e --- /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.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/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/application/pom.xml b/application/pom.xml new file mode 100644 index 00000000..b239a1c0 --- /dev/null +++ b/application/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + dev.molaya.tests + backprods + 0.0.1-SNAPSHOT + + + application + Application Module + + + + ${project.groupId} + domain + + + org.springframework.boot + spring-boot-starter-webflux + + + jakarta.validation + jakarta.validation-api + + + org.projectlombok + lombok-mapstruct-binding + + + org.mapstruct + mapstruct + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.jacoco + jacoco-maven-plugin + + + + + \ No newline at end of file diff --git a/application/src/main/java/dev/molaya/tests/application/in/GetSimilarProductsUseCase.java b/application/src/main/java/dev/molaya/tests/application/in/GetSimilarProductsUseCase.java new file mode 100644 index 00000000..249c05ca --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/in/GetSimilarProductsUseCase.java @@ -0,0 +1,12 @@ +package dev.molaya.tests.application.in; + +import dev.molaya.tests.application.in.dto.GetSimilarProductsInput; +import dev.molaya.tests.domain.Product; +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; +import reactor.core.publisher.Flux; + +@FunctionalInterface +public interface GetSimilarProductsUseCase { + Flux<@NonNull Product> getSimilarProducts(@NotNull GetSimilarProductsInput input); +} diff --git a/application/src/main/java/dev/molaya/tests/application/in/dto/GetSimilarProductsInput.java b/application/src/main/java/dev/molaya/tests/application/in/dto/GetSimilarProductsInput.java new file mode 100644 index 00000000..fd2c6212 --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/in/dto/GetSimilarProductsInput.java @@ -0,0 +1,7 @@ +package dev.molaya.tests.application.in.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record GetSimilarProductsInput(@NotNull String productId) {} diff --git a/application/src/main/java/dev/molaya/tests/application/out/ProductRepositoryPort.java b/application/src/main/java/dev/molaya/tests/application/out/ProductRepositoryPort.java new file mode 100644 index 00000000..36111498 --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/out/ProductRepositoryPort.java @@ -0,0 +1,14 @@ +package dev.molaya.tests.application.out; + +import dev.molaya.tests.application.out.dto.ProductCommand; +import dev.molaya.tests.domain.Product; +import jakarta.validation.constraints.NotNull; +import java.util.Set; +import lombok.NonNull; +import reactor.core.publisher.Mono; + +public interface ProductRepositoryPort { + Mono<@NonNull Product> getProductDetail(@NotNull ProductCommand productCommand); + + Mono<@NonNull Set> getSimilarProductIds(@NotNull ProductCommand productCommand); +} diff --git a/application/src/main/java/dev/molaya/tests/application/out/dto/ProductCommand.java b/application/src/main/java/dev/molaya/tests/application/out/dto/ProductCommand.java new file mode 100644 index 00000000..57d68de8 --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/out/dto/ProductCommand.java @@ -0,0 +1,7 @@ +package dev.molaya.tests.application.out.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record ProductCommand(@NotNull String id) {} diff --git a/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java b/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java new file mode 100644 index 00000000..5a3e26b4 --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java @@ -0,0 +1,22 @@ +package dev.molaya.tests.application.service; + +import dev.molaya.tests.application.in.dto.GetSimilarProductsInput; +import dev.molaya.tests.application.out.dto.ProductCommand; +import dev.molaya.tests.domain.exceptions.BadParametersException; +import jakarta.validation.constraints.NotNull; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.Optional; + +@Mapper(componentModel = "spring") +public interface ServiceSimilarProductsMapper { + @Mapping(target = "id", source = "productId") + ProductCommand toProductCommand(@NotNull GetSimilarProductsInput source); + + default ProductCommand toProductCommandSafe(GetSimilarProductsInput source) { + return Optional.ofNullable(source) + .map(this::toProductCommand) + .orElseThrow(() -> new BadParametersException("Product id should not be null")); + } +} diff --git a/bootstrap/pom.xml b/bootstrap/pom.xml new file mode 100644 index 00000000..2aba9415 --- /dev/null +++ b/bootstrap/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + dev.molaya.tests + backprods + 0.0.1-SNAPSHOT + + + bootstrap + Boostrap Module Project Launcher + + + **/BackProductsApplication.java + + + + + ${project.groupId} + adapters + + + org.springframework.boot + spring-boot-starter + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + dev.molaya.tests.BackProductsApplication + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + \ No newline at end of file diff --git a/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java b/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java new file mode 100644 index 00000000..591b03b8 --- /dev/null +++ b/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java @@ -0,0 +1,11 @@ +package dev.molaya.tests; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BackProductsApplication { + public static void main(String[] args) { + SpringApplication.run(BackProductsApplication.class, args); + } +} diff --git a/bootstrap/src/main/resources/application-local.yaml b/bootstrap/src/main/resources/application-local.yaml new file mode 100644 index 00000000..fcce702c --- /dev/null +++ b/bootstrap/src/main/resources/application-local.yaml @@ -0,0 +1,5 @@ +server.port: 5000 +config: + similar-products: + limit: 20 + parallel-requests: 10 diff --git a/bootstrap/src/main/resources/application.yaml b/bootstrap/src/main/resources/application.yaml new file mode 100644 index 00000000..b1461d33 --- /dev/null +++ b/bootstrap/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +config: ## Added to handle maximum limits of similar products and parallel requests globally (solving tradeoffs by millions of records) + similar-products: + limit: 20 # Modify with specific limit needed (not required new release) + parallel-requests: 5 + +server.port: 5001 + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui + operationSorter: method + +spring: + application: + name: "@pom.artifactId@" +### Disabled because is redundant while using Reactive WebFlux +# threads: +# virtual: +# enabled: true diff --git a/bootstrap/src/test/java/README.md b/bootstrap/src/test/java/README.md new file mode 100644 index 00000000..ef64518c --- /dev/null +++ b/bootstrap/src/test/java/README.md @@ -0,0 +1,4 @@ +## ATDD using cucumber + +Here I use to add acceptance tests using cucumber to validate end-to-end use cases. +Not done due to time constraints. diff --git a/bootstrap/src/test/resources/features/README.md b/bootstrap/src/test/resources/features/README.md new file mode 100644 index 00000000..0279d417 --- /dev/null +++ b/bootstrap/src/test/resources/features/README.md @@ -0,0 +1 @@ +Here are located the *.feature files containing the gherkin scenarios for acceptance tests. diff --git a/docker-compose.yaml b/docker-compose.yaml index 2b20a5d9..2a0470c6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,3 +33,4 @@ services: - K6_OUT=influxdb=http://influxdb:8086/k6 extra_hosts: - "host.docker.internal:host-gateway" + diff --git a/domain/pom.xml b/domain/pom.xml new file mode 100644 index 00000000..a735af0f --- /dev/null +++ b/domain/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + + dev.molaya.tests + backprods + 0.0.1-SNAPSHOT + + + domain + Domain Module + + + **/domain/** + + + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + \ No newline at end of file diff --git a/domain/src/main/java/dev/molaya/tests/domain/Product.java b/domain/src/main/java/dev/molaya/tests/domain/Product.java new file mode 100644 index 00000000..1aaf6b26 --- /dev/null +++ b/domain/src/main/java/dev/molaya/tests/domain/Product.java @@ -0,0 +1,7 @@ +package dev.molaya.tests.domain; + +import java.math.BigDecimal; +import lombok.Builder; + +@Builder(toBuilder = true) +public record Product(String id, String name, BigDecimal price, boolean available) {} diff --git a/domain/src/main/java/dev/molaya/tests/domain/exceptions/BadParametersException.java b/domain/src/main/java/dev/molaya/tests/domain/exceptions/BadParametersException.java new file mode 100644 index 00000000..a4ee35ff --- /dev/null +++ b/domain/src/main/java/dev/molaya/tests/domain/exceptions/BadParametersException.java @@ -0,0 +1,7 @@ +package dev.molaya.tests.domain.exceptions; + +public class BadParametersException extends RuntimeException { + public BadParametersException(String message) { + super(message); + } +} diff --git a/domain/src/main/java/dev/molaya/tests/domain/exceptions/IntegrationException.java b/domain/src/main/java/dev/molaya/tests/domain/exceptions/IntegrationException.java new file mode 100644 index 00000000..dbd3d9fb --- /dev/null +++ b/domain/src/main/java/dev/molaya/tests/domain/exceptions/IntegrationException.java @@ -0,0 +1,7 @@ +package dev.molaya.tests.domain.exceptions; + +public class IntegrationException extends RuntimeException { + public IntegrationException(String message, Throwable e) { + super(message, e); + } +} diff --git a/mvnw b/mvnw new file mode 100755 index 00000000..bd8896bf --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..92450f93 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..51490bb5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,304 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + dev.molaya.tests + backprods + 0.0.1-SNAPSHOT + Dev Products Tech Solution + Backend dev test solution by molaya + + + molaya + Marlon Olaya + me@molaya.dev + + + pom + + 21 + + 0.8.13 + 0.8 + **/dto/**,**/model/**,**/exception/** + + 2.46.1 + 2.71.0 + + 7.17.0 + 0.2.8 + 3.1.1 + 2.2.41 + 2.20.1 + + 1.6.3 + 0.2.0 + 1.18.38 + + + + domain + application + adapters + bootstrap + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + dev.molaya.tests + domain + ${project.version} + + + dev.molaya.tests + application + ${project.version} + + + dev.molaya.tests + adapters + ${project.version} + + + org.projectlombok + lombok + ${lombok.version} + true + + + + org.projectlombok + lombok-mapstruct-binding + ${lombok.mapstruct.binding.version} + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson-datatype-jsr310.version} + + + + io.swagger.core.v3 + swagger-annotations + ${swagger-annotations.version} + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation-api.version} + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + + + + org.jacoco + jacoco-maven-plugin + + true + + ${jacoco-maven-plugin.excludes}, + **/dto/**, + **/config/**, + **/exceptions/** + + + + + agent-for-ut + + prepare-agent + + + + agent-for-it + + prepare-agent-integration + + + + jacoco-site + verify + + report + + + + jacoco-check + + check + + + ${jacoco-maven-plugin.excludes} + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${jacoco-maven-plugin.minimum} + + + + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + + com.palantir.javaformat + palantir-java-format + ${palantir-java-format.version} + + + + + + + src/**/resources/**/*.xml + src/**/resources/**/*.xsd + + + + true + 4 + + + + + + + pom.xml + + + + + ${palantir-java-format.version} + + + + + + + + src/**/*.yaml + + + + + + src/**/*.feature + + + + + + **/*.md + + + + + + + + + + + maven-surefire-plugin + ${maven-surefire-plugin.version} + + false + **/*Test.java + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + true + ${java.version} + ${java.version} + true + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + ${lombok.mapstruct.binding.version} + + + + + -Amapstruct.defaultComponentModel=spring + + + + + + + + + + + diff --git a/readme.md b/readme.md index bc0c5cc7..3a1d9a23 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,8 @@ # Backend dev technical test -We want to offer a new feature to our customers showing similar products to the one they are currently seeing. To do this we agreed with our front-end applications to create a new REST API operation that will provide them the product detail of the similar products for a given one. [Here](./similarProducts.yaml) is the contract we agreed. -We already have an endpoint that provides the product Ids similar for a given one. We also have another endpoint that returns the product detail by product Id. [Here](./existingApis.yaml) is the documentation of the existing APIs. +We want to offer a new feature to our customers showing similar products to the one they are currently seeing. To do this we agreed with our front-end applications to create a new REST API operation that will provide them the product detail of the similar products for a given one. [Here](./adapters/src/main/resources/similarProducts.yaml) is the contract we agreed. + +We already have an endpoint that provides the product Ids similar for a given one. We also have another endpoint that returns the product detail by product Id. [Here](./adapters/src/main/resources/existingApis.yaml) is the documentation of the existing APIs. **Create a Spring boot application that exposes the agreed REST API on port 5000.** @@ -9,24 +10,143 @@ We already have an endpoint that provides the product Ids similar for a given on Note that _Test_ and _Mocks_ components are given, you must only implement _yourApp_. +## Design Decisions & Considerations + +Before starting the implementation, I analyzed the requirements and identified a significant scalability challenge regarding the API contract. + +### Scalability Challenge and performance analysis and considerations + +I identified a potential trade-off in scenarios where the volume of data retrieved might **exceed** standard limits. +In a real-world scenario, a product could potentially have thousands or millions of similar matches. Since the current API contract does not define any pagination or limits, attempting to fetch details for all of them would cause: +* **Performance degradation:** High latency and memory consumption. +* **System instability:** Potential saturation of downstream services. + +**The Solution: Top-N Approach** +Since modifying the API contract to add pagination was outside the scope, I decided to implement a **Top-N truncation strategy**. +* The system fetches the IDs but only processes the details for the most relevant ones (e.g., the first 20). +* This approach ensures O(1) memory usage regardless of the input size, significantly improving performance and resilience. + +**Configuration:** +* `CONFIG_SIMILAR_PRODUCTS_LIMIT` env var or `config.similiar-products.limit` yaml prop: Determines the max number of products to return (Default: `20`). +* `CONFIG_SIMILAR_PRODUCTS_PARALLEL_REQUESTS` env var or `config.similar-products.parallel-requests`yaml prop: Determines the concurrency level for parallel fetching (Default: `10`). + +> Host for existingAPIs connection is wrapped in the adapter by the open api generator (this is good for integration), but sometimes is not good approach letting external or third party library generate code like that. +> Anyway, for this test I kept it as is to focus on the main requirements. + +### Tech Stack: Why WebFlux? + +I chose **Spring WebFlux** to build a reactive, non-blocking application and aligned with functional programming. And the needs for this feature, require a consideration for millions or thousands of results from similar products ids endpoint. +In that case, a traditional blocking approach would lead to thread exhaustion and poor scalability. (except using Virtual Threads, see below). + +**Why not Virtual Threads (Java 21)?** +I evaluated using Spring MVC with Java 21 Virtual Threads (Project Loom), which is a valid modern approach for I/O-bound tasks. However, I decided on **WebFlux** for two reasons: +1. **Scatter-Gather Pattern:** The requirement involves fetching multiple resources in parallel and aggregating them. WebFlux's functional operators (`flatMap`, `zip`, `collectList`) provide a more declarative and natural way to model this specific flow compared to imperative loops. +2. **Personal Proficiency:** I strongly believe that the declarative programming model results in more readable and maintainable code for stream processing tasks like this one. + +### Other Approaches Evaluated + +To have in context, here are other strategies I considered during the design phase: +* **Virtual Threads:** Excellent for blocking I/O, but requires more manual orchestration to achieve the same declarative elegance as WebFlux for this specific aggregation use case. +* **Pagination:** The ideal solution, but it would require breaking the provided API contract. (and thus was not implemented). +* **Caching:** Implementing a caching layer (e.g., Caffeine/Redis) for product details would be the next logical step for production readiness to reduce network overhead. (requires additional infrastructure not covered in this test). + +--- + +## Standardization and Code Structure (Hexagonal Architecture) + +I structured the code following a **Multi-Module Maven** approach based on **Hexagonal Architecture** (Ports & Adapters). This promotes strictly separated concerns and testability. + +I deliberately separated the *Inbound* and *Outbound* adapters into different modules to enforce dependency inversion and prevent architectural leakage. + +> I now this is simply a small project, but I wanted to demonstrate best practices that would scale in larger applications. + +* **domain**: The core. Contains entities and pure business logic. It has **zero dependencies** on frameworks or external libraries. +* **application**: Orchestration layer. Contains the `Services` and defines the `Ports` (interfaces) that the infrastructure layer must implement, also some application configurations like similar products limits. +* **adapters**: Includes in and out ports implementations: + * The entry point. Handles incoming HTTP requests and maps them to application use cases. + * The exit point. Implements the outbound ports using `WebClient` to interact with external services (Mocks). +* **bootstrap**: The Spring Boot application entry point that assembles all modules. + +### Spotless & Code Formatting + +To ensure consistent code style and formatting across the project, I integrated **Spotless** in the project. +Usually what I do is to define a common formatting configuration in the parent POM, and then each module inherits it. Then using Jenkins or any CI staging tool, I enforce the formatting check as part of the build process. +For this project I kept it simple and just added the plugin to the parent POM. And manually run it before committing. + +To check the code status, run: + +```bash +mvn spotless:check +``` + +To automatically format the code, run: + +```bash +mvn spotless:apply +``` + +**This improves code readability and maintainability across the team.** + +--- + +### Coverage + +Project will contain Jacoco code coverage reports and unit tests for critical components. +This is a common practice I use to promove in the team cultures, to ensure code quality and reliability. +Again as before it is added to parent POM and inherited by all modules. +Should be run as part of CI/CD pipeline. +To run tests and generate reports, use: + +```bash +mvn clean test +``` + +> Preferred to be run in stage split from build process to isolate failures early, and speed up CI/CD flow. + +--- + +### API Contract & Libraries + +Following a **Contract-First** approach, I coupled the implementation directly to the provided OpenAPI specifications (`yaml` files). + +* **OpenAPI Generator:** Used to automatically generate the REST Controller interfaces (Server) and the WebClient consumers (Client). +* **Benefits:** This ensures strict adherence to the contract. Any change in the YAML files will break the build, providing immediate feedback and preventing drift between documentation and code. + +--- + +### ROADMAP + +Future improvements and features that could be added to enhance the project and demonstrate current best practices and skills, but not done because of time constraints: +* **Integration tests using cucumber**: Implement end-to-end tests that validate the entire flow from HTTP request to external service calls using Cucumber. +* **Server Configuration integration**: Support dynamic configuration management using Spring Cloud Config or similar. +* **Resilience patterns**: Implement circuit breakers, retries, and fallbacks using Resilience4j to enhance fault tolerance. +* **Security**: Restrict access by Mutual TLS or OAuth2. +* **Observability**: Add logging, metrics, and tracing (e.g., Micrometer, OpenTelemetry). + ## Testing and Self-evaluation + You can run the same test we will put through your application. You just need to have docker installed. First of all, you may need to enable file sharing for the `shared` folder on your docker dashboard -> settings -> resources -> file sharing. Then you can start the mocks and other needed infrastructure with the following command. + ``` docker-compose up -d simulado influxdb grafana ``` + Check that mocks are working with a sample request to [http://localhost:3001/product/1/similarids](http://localhost:3001/product/1/similarids). To execute the test run: + ``` docker-compose run --rm k6 run scripts/test.js ``` + Browse [http://localhost:3000/d/Le2Ku9NMk/k6-performance-test](http://localhost:3000/d/Le2Ku9NMk/k6-performance-test) to view the results. ## Evaluation + The following topics will be considered: - Code clarity and maintainability - Performance diff --git a/start.sh b/start.sh new file mode 100644 index 00000000..9c28de12 --- /dev/null +++ b/start.sh @@ -0,0 +1,7 @@ +#!/bin/sh +echo "******************************************************************" +echo "* We can add certs in this point to the Java keystore if needed *" +echo "* Starting app... *" + +java $JAVA_OPTS -jar /app/app.jar +echo "* Loading app... *" \ No newline at end of file From ccd81f881f165ea3334b027c74c795dce7be8c30 Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 12:14:31 +0100 Subject: [PATCH 2/9] feat(adapters-in): rest adapter implementation and unit test --- .../in/rest/SimilarProductsController.java | 31 ++++++ .../SimilarProductsControllerAdvice.java | 33 +++++++ .../java/dev/molaya/tests/adapters/Stubs.java | 40 ++++++++ .../rest/RestSimilarProductsMapperTest.java | 80 ++++++++++++++++ .../rest/SimilarProductsControllerTest.java | 95 +++++++++++++++++++ .../SimilarProductsControllerWebFluxTest.java | 83 ++++++++++++++++ .../SimilarProductsControllerAdviceTest.java | 42 ++++++++ 7 files changed, 404 insertions(+) create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/in/rest/SimilarProductsController.java create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdvice.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/in/rest/RestSimilarProductsMapperTest.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerTest.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/in/rest/SimilarProductsControllerWebFluxTest.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdviceTest.java 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..c526b511 --- /dev/null +++ b/adapters/src/main/java/dev/molaya/tests/adapters/in/rest/SimilarProductsController.java @@ -0,0 +1,31 @@ +package dev.molaya.tests.adapters.in.rest; + +import dev.molaya.tests.adapters.in.rest.dto.ProductDetail; +import dev.molaya.tests.application.in.GetSimilarProductsUseCase; +import jakarta.validation.constraints.NotEmpty; +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.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("/products/{productId}/similar") + public Mono>> getProductSimilar( + @NotEmpty String productId, ServerWebExchange exchange) { + 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/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..e65458ec --- /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.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.dto.ProductDetail productDetailOUT() { + final var detail = new dev.molaya.tests.adapters.out.client.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..7ae99333 --- /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.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("/products/{id}/similar", productId) + .exchange() + .expectStatus() + .isOk() + .returnResult(ProductDetail.class); + assertNotNull(response.getResponseBody()); + } + } + + private static Answer callRealMapper() { + return val -> REAL_MAPPER.toRestProductDetail(val.getArgument(0)); + } + + @Nested + class Exceptions { + @ParameterizedTest + @ValueSource(strings = {"A", " "}) + void returnsEmptyWhenNoProducts(final String productId) { + when(useCase.getSimilarProducts(any())).thenReturn(Flux.empty()); + final var response = webTestClient + .get() + .uri("/products/{id}/similar", productId) + .exchange() + .expectStatus() + .isOk() + .returnResult(ProductDetail.class); + assertEquals(0L, response.getResponseBody().count().block()); + } + } +} diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdviceTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdviceTest.java new file mode 100644 index 00000000..1748769e --- /dev/null +++ b/adapters/src/test/java/dev/molaya/tests/adapters/in/rest/advice/SimilarProductsControllerAdviceTest.java @@ -0,0 +1,42 @@ +package dev.molaya.tests.adapters.in.rest.advice; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import dev.molaya.tests.domain.exceptions.IntegrationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SimilarProductsControllerAdviceTest { + @InjectMocks + private SimilarProductsControllerAdvice advice; + + @Test + void handleException() { + final var exception = new Exception("Test Exception"); + final var response = advice.handleException(exception); + assertNotNull(response); + assertEquals(500, response.getStatusCode().value()); + final var apiError = response.getBody(); + assertNotNull(apiError); + assertEquals("MOLAR-500", apiError.code()); + assertEquals("Unknown error occurred", apiError.message()); + assertEquals("Test Exception", apiError.detail()); + } + + @Test + void handleIntegrationException() { + final var exception = new IntegrationException("Integration failure", null); + final var response = advice.handleIntegrationException(exception); + assertNotNull(response); + assertEquals(409, response.getStatusCode().value()); + final var apiError = response.getBody(); + assertNotNull(apiError); + assertEquals("MOLAR-409", apiError.code()); + assertEquals("Integration with external service failed", apiError.message()); + assertEquals("Integration failure", apiError.detail()); + } +} From 87ef69fe611f6a9059c6c283d4b4fb521181dca4 Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 12:14:55 +0100 Subject: [PATCH 3/9] feat(adapters-out): client adapter implementation and unit test --- .../ClientProductRepositoryAdapter.java | 37 +++++++++ .../ClientProductRepositoryAdapterTest.java | 80 +++++++++++++++++++ .../ClientSimilarProductsMapperTest.java | 30 +++++++ 3 files changed, 147 insertions(+) create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapter.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java create mode 100644 adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapperTest.java 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..05cb63e1 --- /dev/null +++ b/adapters/src/main/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapter.java @@ -0,0 +1,37 @@ +package dev.molaya.tests.adapters.out.client; + +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)) + .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)) + .doOnError(e -> log.warn("Failed to fetch similar product ids for product: {}", id)) + .onErrorResume(e -> Mono.just(Collections.emptySet())); + } +} diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java new file mode 100644 index 00000000..2ced478e --- /dev/null +++ b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java @@ -0,0 +1,80 @@ +package dev.molaya.tests.adapters.out.client; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import dev.molaya.tests.adapters.Stubs; +import dev.molaya.tests.application.out.dto.ProductCommand; +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 reactor.core.publisher.Mono; + +@ExtendWith(MockitoExtension.class) +class ClientProductRepositoryAdapterTest { + @InjectMocks + private ClientProductRepositoryAdapter adapter; + + @Mock + private DefaultGenApi defaultApi; + + @Mock + private ClientSimilarProductsMapper mapper; + + @Nested + class HappyPath { + @ParameterizedTest + @ValueSource(strings = {"1", "2"}) + void getProductDetail(final String productId) { + final var input = new ProductCommand(productId); + when(defaultApi.getProductProductId(productId)).thenReturn(Mono.just(Stubs.productDetailOUT())); + when(mapper.toDomainProduct(any())).thenReturn(Stubs.product()); + final var result = adapter.getProductDetail(input).block(); + assertNotNull(result); + assertEquals(Stubs.product().id(), result.id()); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "2"}) + void getSimilarProductIds(final String productId) { + final var input = new ProductCommand(productId); + when(defaultApi.getProductSimilarids(productId)).thenReturn(Mono.just(Stubs.similarIds())); + final var result = adapter.getSimilarProductIds(input).block(); + assertNotNull(result); + assertTrue(result.contains("A")); + } + } + + @Nested + class Exceptions { + @Test + void getProductDetailError_notThrownException() { + final var input = new ProductCommand("productId"); + when(defaultApi.getProductProductId(any())).thenReturn(Mono.error(new RuntimeException("fail"))); + assertDoesNotThrow(() -> adapter.getProductDetail(input).block()); + } + + @Test + void getProductDetailErrorOutOfFlow_doesNotThrowException() { + final var input = new ProductCommand("productId"); + when(defaultApi.getProductProductId(any())).thenThrow(new RuntimeException("fail")); + assertDoesNotThrow(() -> adapter.getProductDetail(input).block()); + } + + @Test + void getProductDetailError_throwExceptionOnNullInput() { + assertThrows( + RuntimeException.class, () -> adapter.getProductDetail(null).block()); + } + } +} diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapperTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapperTest.java new file mode 100644 index 00000000..92956369 --- /dev/null +++ b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientSimilarProductsMapperTest.java @@ -0,0 +1,30 @@ +package dev.molaya.tests.adapters.out.client; + +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 dev.molaya.tests.adapters.Stubs; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +class ClientSimilarProductsMapperTest { + private final ClientSimilarProductsMapper mapper = Mappers.getMapper(ClientSimilarProductsMapper.class); + + @Test + void toDomainProduct() { + final var source = Stubs.productDetailOUT(); + final var result = mapper.toDomainProduct(source); + assertNotNull(result); + assertEquals(source.getAvailability(), result.available()); + assertEquals(source.getId(), result.id()); + assertEquals(source.getName(), result.name()); + assertEquals(source.getPrice(), result.price()); + } + + @Test + void toDomainProduct_NullSource() { + final var result = mapper.toDomainProduct(null); + assertNull(result); + } +} From f0086cbb0ddbdb83307efc293751205b729678d8 Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 12:15:51 +0100 Subject: [PATCH 4/9] feat(application): add properties configuration for top-k similar products strategy --- .../config/SimilarProductsConfigurationProperties.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java diff --git a/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java b/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java new file mode 100644 index 00000000..59ed4766 --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java @@ -0,0 +1,10 @@ +package dev.molaya.tests.application.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "config.similar-products") +public record SimilarProductsConfigurationProperties( + @DefaultValue("20") int limit, @DefaultValue("20") int parallelRequests) {} From 27fce10955f188ed57ee685ae4c9212096193f28 Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 12:15:56 +0100 Subject: [PATCH 5/9] feat(application): application use case implementation and unit test --- .../service/SimilarProductsService.java | 42 +++++ .../ServiceSimilarProductsMapperTest.java | 35 ++++ .../service/SimilarProductsServiceTest.java | 153 ++++++++++++++++++ .../tests/application/service/Stubs.java | 33 ++++ 4 files changed, 263 insertions(+) create mode 100644 application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java create mode 100644 application/src/test/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapperTest.java create mode 100644 application/src/test/java/dev/molaya/tests/application/service/SimilarProductsServiceTest.java create mode 100644 application/src/test/java/dev/molaya/tests/application/service/Stubs.java diff --git a/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java b/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java new file mode 100644 index 00000000..e03f2dbb --- /dev/null +++ b/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java @@ -0,0 +1,42 @@ +package dev.molaya.tests.application.service; + +import dev.molaya.tests.application.config.SimilarProductsConfigurationProperties; +import dev.molaya.tests.application.in.GetSimilarProductsUseCase; +import dev.molaya.tests.application.in.dto.GetSimilarProductsInput; +import dev.molaya.tests.application.out.ProductRepositoryPort; +import dev.molaya.tests.application.out.dto.ProductCommand; +import dev.molaya.tests.domain.Product; +import dev.molaya.tests.domain.exceptions.IntegrationException; +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SimilarProductsService implements GetSimilarProductsUseCase { + private final ProductRepositoryPort port; + private final ServiceSimilarProductsMapper mapper; + private final SimilarProductsConfigurationProperties properties; + + @Override + public Flux<@NonNull Product> getSimilarProducts(@NotNull GetSimilarProductsInput input) { + final var command = mapper.toProductCommandSafe(input); + + return Flux.defer(() -> getProductCommandsForIdsSafe(command) + .flatMap(port::getProductDetail, properties.parallelRequests())) + .doOnError(e -> log.error("Error fetching similar products for product: {}", input.productId(), e)) + .onErrorMap(e -> new IntegrationException("Failed to fetch product details", e)); + } + + private Flux getProductCommandsForIdsSafe(ProductCommand command) { + return Mono.defer(() -> port.getSimilarProductIds(command)) + .flatMapMany(Flux::fromIterable) + .take(properties.limit()) + .map(ProductCommand::new); + } +} diff --git a/application/src/test/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapperTest.java b/application/src/test/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapperTest.java new file mode 100644 index 00000000..8ab33107 --- /dev/null +++ b/application/src/test/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapperTest.java @@ -0,0 +1,35 @@ +package dev.molaya.tests.application.service; + +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.application.in.dto.GetSimilarProductsInput; +import dev.molaya.tests.domain.exceptions.BadParametersException; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +class ServiceSimilarProductsMapperTest { + private final ServiceSimilarProductsMapper mapper = Mappers.getMapper(ServiceSimilarProductsMapper.class); + + @Test + void toProductCommand() { + final var input = new GetSimilarProductsInput("12345"); + final var result = mapper.toProductCommand(input); + assertNotNull(result); + assertEquals(input.productId(), result.id()); + } + + @Test + void toProductCommand_NullInput() { + final var result = mapper.toProductCommand(null); + assertNull(result); + } + + @Test + void toProductCommandSafe_NullInput() { + final var exception = assertThrows(BadParametersException.class, () -> mapper.toProductCommandSafe(null)); + assertEquals("Product id should not be null", exception.getMessage()); + } +} diff --git a/application/src/test/java/dev/molaya/tests/application/service/SimilarProductsServiceTest.java b/application/src/test/java/dev/molaya/tests/application/service/SimilarProductsServiceTest.java new file mode 100644 index 00000000..87655dd3 --- /dev/null +++ b/application/src/test/java/dev/molaya/tests/application/service/SimilarProductsServiceTest.java @@ -0,0 +1,153 @@ +package dev.molaya.tests.application.service; + +import static dev.molaya.tests.application.service.Stubs.productIds; +import static dev.molaya.tests.application.service.Stubs.validCommand; +import static dev.molaya.tests.application.service.Stubs.validInput; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import dev.molaya.tests.application.config.SimilarProductsConfigurationProperties; +import dev.molaya.tests.application.in.dto.GetSimilarProductsInput; +import dev.molaya.tests.application.out.ProductRepositoryPort; +import dev.molaya.tests.application.out.dto.ProductCommand; +import dev.molaya.tests.domain.Product; +import dev.molaya.tests.domain.exceptions.IntegrationException; +import java.util.List; +import java.util.Set; +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.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +@ExtendWith(MockitoExtension.class) +class SimilarProductsServiceTest { + @InjectMocks + private SimilarProductsService similarProductsService; + + @Mock + private ServiceSimilarProductsMapper mapper; + + @Mock + private ProductRepositoryPort port; + + @Mock + private SimilarProductsConfigurationProperties properties; + + @Nested + class HappyPath { + @ParameterizedTest + @MethodSource("dev.molaya.tests.application.service.Stubs#validInputs") + void returnsProducts(final GetSimilarProductsInput input) { + final var command = Stubs.validCommand(input.productId()); + when(mapper.toProductCommandSafe(Mockito.any())).thenReturn(command); + when(port.getSimilarProductIds(Mockito.any())).thenReturn(Mono.just(productIds())); + when(properties.limit()).thenReturn(2); + when(properties.parallelRequests()).thenReturn(1); + when(port.getProductDetail(Mockito.any())) + .thenAnswer(inv -> Mono.just(Stubs.product( + inv.getArgument(0, ProductCommand.class).id()))); + + final var result = similarProductsService + .getSimilarProducts(input) + .collectList() + .block(); + + assertNotNull(result); + assertEquals(2, result.size()); + } + } + + @Nested + class CornerCases { + @ParameterizedTest + @MethodSource("dev.molaya.tests.application.service.Stubs#productIdLists") + void handlesLimits(final Set ids) { + final var input = Stubs.validInput("1"); + final var command = Stubs.validCommand(input.productId()); + when(mapper.toProductCommandSafe(Mockito.any())).thenReturn(command); + when(port.getSimilarProductIds(Mockito.any())).thenReturn(Mono.just(ids)); + when(properties.limit()).thenReturn(1); + when(properties.parallelRequests()).thenReturn(1); + lenient() + .when(port.getProductDetail(any())) + .thenAnswer(inv -> Mono.just(Stubs.product( + inv.getArgument(0, ProductCommand.class).id()))); + + final var result = similarProductsService + .getSimilarProducts(input) + .collectList() + .block(); + + assertNotNull(result); + assertTrue(result.size() <= 1); + } + } + + @Nested + class InvalidParameters { + @ParameterizedTest + @ValueSource(strings = {"", " ", "null"}) + void invalidInput_returnsEmpty(final String id) { + final var input = validInput(id); + final var command = new ProductCommand(id); + when(mapper.toProductCommandSafe(any())).thenReturn(command); + lenient().when(port.getSimilarProductIds(any())).thenReturn(Mono.just(Set.of())); + when(properties.limit()).thenReturn(1); + when(properties.parallelRequests()).thenReturn(1); + + final List result = similarProductsService + .getSimilarProducts(input) + .collectList() + .block(); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void nullInput_throwsIntegrationException() { + assertThrows( + IntegrationException.class, + () -> similarProductsService.getSimilarProducts(null).blockLast()); + } + } + + @Nested + class Exceptions { + @Test + void similarIdsThrows_throwsIntegrationException() { + final var input = validInput("1"); + final var command = validCommand(input.productId()); + when(mapper.toProductCommandSafe(any())).thenReturn(command); + when(port.getSimilarProductIds(any())).thenThrow(new RuntimeException("failed")); + when(properties.limit()).thenReturn(1); + when(properties.parallelRequests()).thenReturn(1); + final var flux = similarProductsService.getSimilarProducts(input); + assertThrows(IntegrationException.class, flux::blockLast); + } + + @Test + void productDetailThrows_throwsIntegrationException() { + final var input = Stubs.validInput("1"); + final var command = Stubs.validCommand(input.productId()); + when(mapper.toProductCommandSafe(any())).thenReturn(command); + when(port.getSimilarProductIds(any())).thenReturn(Mono.just(Set.of("2"))); + when(properties.limit()).thenReturn(1); + when(properties.parallelRequests()).thenReturn(1); + when(port.getProductDetail(any())).thenThrow(new RuntimeException("fail")); + final var flux = similarProductsService.getSimilarProducts(input); + assertThrows(IntegrationException.class, flux::blockLast); + } + } +} diff --git a/application/src/test/java/dev/molaya/tests/application/service/Stubs.java b/application/src/test/java/dev/molaya/tests/application/service/Stubs.java new file mode 100644 index 00000000..87732cf5 --- /dev/null +++ b/application/src/test/java/dev/molaya/tests/application/service/Stubs.java @@ -0,0 +1,33 @@ +package dev.molaya.tests.application.service; + +import dev.molaya.tests.application.in.dto.GetSimilarProductsInput; +import dev.molaya.tests.application.out.dto.ProductCommand; +import dev.molaya.tests.domain.Product; +import java.util.Set; +import java.util.stream.Stream; + +public class Stubs { + public static GetSimilarProductsInput validInput(final String id) { + return GetSimilarProductsInput.builder().productId(id).build(); + } + + public static ProductCommand validCommand(final String id) { + return new ProductCommand(id); + } + + public static Set productIds() { + return Set.of("2", "3", "4"); + } + + public static Product product(final String id) { + return Product.builder().id(id).name("Product " + id).build(); + } + + public static Stream validInputs() { + return Stream.of(validInput("1"), validInput("2")); + } + + public static Stream> productIdLists() { + return Stream.of(Set.of(), Set.of("2"), Set.of("2", "3", "4", "5")); + } +} From ca146cffdeb9e522c84079d9e4869f7b6cc9c3ee Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 12:36:50 +0100 Subject: [PATCH 6/9] test(adapters): move openapi generated code to other package to exclude from unit tests --- adapters/pom.xml | 6 +++--- .../in/rest/RestSimilarProductsMapper.java | 5 +++-- .../in/rest/SimilarProductsController.java | 3 ++- .../client/ClientProductRepositoryAdapter.java | 6 ++++-- .../out/client/ClientSimilarProductsMapper.java | 2 +- .../java/dev/molaya/tests/adapters/Stubs.java | 6 +++--- .../SimilarProductsControllerWebFluxTest.java | 12 ++++++------ .../ClientProductRepositoryAdapterTest.java | 17 +++++++++-------- 8 files changed, 31 insertions(+), 26 deletions(-) diff --git a/adapters/pom.xml b/adapters/pom.xml index 8e83a491..b1b84318 100644 --- a/adapters/pom.xml +++ b/adapters/pom.xml @@ -14,11 +14,11 @@ Adapters Module - dev.molaya.tests.adapters.out.client - dev.molaya.tests.adapters.in.rest + dev.molaya.tests.adapters.out.client.gen.openapi + dev.molaya.tests.adapters.in.rest.gen.openapi 2.8.6 GenApi - **/*${openapi-generator-maven-plugin.apiNameSuffix}.java + **/auth/**,**/gen/** 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 index 37d6addc..ac362a34 100644 --- 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 @@ -1,16 +1,17 @@ package dev.molaya.tests.adapters.in.rest; -import dev.molaya.tests.adapters.in.rest.dto.ProductDetail; +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; +import java.util.Optional; + @Mapper public interface RestSimilarProductsMapper { 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 index c526b511..35302fe2 100644 --- 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 @@ -1,6 +1,7 @@ package dev.molaya.tests.adapters.in.rest; -import dev.molaya.tests.adapters.in.rest.dto.ProductDetail; +import dev.molaya.tests.adapters.in.rest.gen.openapi.dto.ProductDetail; +import dev.molaya.tests.adapters.in.rest.gen.openapi.ProductGenApi; import dev.molaya.tests.application.in.GetSimilarProductsUseCase; import jakarta.validation.constraints.NotEmpty; import lombok.RequiredArgsConstructor; 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 index 05cb63e1..4950589b 100644 --- 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 @@ -1,17 +1,19 @@ 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; +import java.util.Collections; +import java.util.Set; + @Slf4j @Service @RequiredArgsConstructor 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 index 4b896a2e..3c408a8f 100644 --- 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 @@ -1,6 +1,6 @@ package dev.molaya.tests.adapters.out.client; -import dev.molaya.tests.adapters.out.client.dto.ProductDetail; +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; diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java b/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java index e65458ec..73a0de1e 100644 --- a/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java +++ b/adapters/src/test/java/dev/molaya/tests/adapters/Stubs.java @@ -1,6 +1,6 @@ package dev.molaya.tests.adapters; -import dev.molaya.tests.adapters.in.rest.dto.ProductDetail; +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; @@ -25,8 +25,8 @@ public static ProductDetail productDetail() { return detail; } - public static dev.molaya.tests.adapters.out.client.dto.ProductDetail productDetailOUT() { - final var detail = new dev.molaya.tests.adapters.out.client.dto.ProductDetail(); + 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)); 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 index 7ae99333..ced2a4c5 100644 --- 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 @@ -1,13 +1,8 @@ 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.dto.ProductDetail; +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; @@ -22,6 +17,11 @@ import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; +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; + @WebFluxTest(controllers = SimilarProductsController.class) @Import(AdapterTestApplication.class) class SimilarProductsControllerWebFluxTest { diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java index 2ced478e..aff60ae9 100644 --- a/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java +++ b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java @@ -1,14 +1,7 @@ package dev.molaya.tests.adapters.out.client; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -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.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - import dev.molaya.tests.adapters.Stubs; +import dev.molaya.tests.adapters.out.client.gen.openapi.DefaultGenApi; import dev.molaya.tests.application.out.dto.ProductCommand; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,6 +13,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + @ExtendWith(MockitoExtension.class) class ClientProductRepositoryAdapterTest { @InjectMocks From 4bc8b917fe567052913f4b824306d283821090be Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 14:34:49 +0100 Subject: [PATCH 7/9] feat(configuration): improve configuration properties --- application/pom.xml | 4 ++-- .../config/SimilarProductsConfigurationProperties.java | 2 -- .../java/dev/molaya/tests/BackProductsApplication.java | 3 +++ bootstrap/src/main/resources/application-local.yaml | 2 +- bootstrap/src/main/resources/application.yaml | 10 +++++++--- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index b239a1c0..1693c6b9 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -23,8 +23,8 @@ spring-boot-starter-webflux - jakarta.validation - jakarta.validation-api + org.springframework.boot + spring-boot-starter-validation org.projectlombok diff --git a/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java b/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java index 59ed4766..1798b107 100644 --- a/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java +++ b/application/src/main/java/dev/molaya/tests/application/config/SimilarProductsConfigurationProperties.java @@ -2,9 +2,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; -import org.springframework.stereotype.Component; -@Component @ConfigurationProperties(prefix = "config.similar-products") public record SimilarProductsConfigurationProperties( @DefaultValue("20") int limit, @DefaultValue("20") int parallelRequests) {} diff --git a/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java b/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java index 591b03b8..80ab3e51 100644 --- a/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java +++ b/bootstrap/src/main/java/dev/molaya/tests/BackProductsApplication.java @@ -1,9 +1,12 @@ package dev.molaya.tests; +import dev.molaya.tests.application.config.SimilarProductsConfigurationProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties(SimilarProductsConfigurationProperties.class) public class BackProductsApplication { public static void main(String[] args) { SpringApplication.run(BackProductsApplication.class, args); diff --git a/bootstrap/src/main/resources/application-local.yaml b/bootstrap/src/main/resources/application-local.yaml index fcce702c..80587178 100644 --- a/bootstrap/src/main/resources/application-local.yaml +++ b/bootstrap/src/main/resources/application-local.yaml @@ -1,5 +1,5 @@ server.port: 5000 config: similar-products: - limit: 20 + limit: 30 parallel-requests: 10 diff --git a/bootstrap/src/main/resources/application.yaml b/bootstrap/src/main/resources/application.yaml index b1461d33..67421a2d 100644 --- a/bootstrap/src/main/resources/application.yaml +++ b/bootstrap/src/main/resources/application.yaml @@ -1,9 +1,9 @@ config: ## Added to handle maximum limits of similar products and parallel requests globally (solving tradeoffs by millions of records) similar-products: - limit: 20 # Modify with specific limit needed (not required new release) - parallel-requests: 5 + limit: 30 # Modify with specific limit needed (not required new release) + parallel-requests: 10 -server.port: 5001 +server.port: 5000 springdoc: api-docs: @@ -13,6 +13,10 @@ springdoc: operationSorter: method spring: + devtools: + add-properties: false + restart: + enabled: false application: name: "@pom.artifactId@" ### Disabled because is redundant while using Reactive WebFlux From 1a980394e1969a4dd2ae5667992fb0abdfafd5f4 Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 17:47:41 +0100 Subject: [PATCH 8/9] feat(logging): improve logging and logic implementation --- .../in/rest/RestSimilarProductsMapper.java | 3 +-- .../in/rest/SimilarProductsController.java | 15 +++++++++++---- .../client/ClientProductRepositoryAdapter.java | 7 ++++--- .../config/ClientWebClientConfiguration.java | 13 +++++++++++++ .../SimilarProductsControllerWebFluxTest.java | 14 +++++++------- .../ClientProductRepositoryAdapterTest.java | 16 ++++++++-------- .../service/ServiceSimilarProductsMapper.java | 3 +-- .../service/SimilarProductsService.java | 1 - 8 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 adapters/src/main/java/dev/molaya/tests/adapters/out/client/config/ClientWebClientConfiguration.java 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 index ac362a34..8a924996 100644 --- 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 @@ -4,14 +4,13 @@ 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; -import java.util.Optional; - @Mapper public interface RestSimilarProductsMapper { 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 index 35302fe2..1911bb8e 100644 --- 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 @@ -1,13 +1,16 @@ package dev.molaya.tests.adapters.in.rest; -import dev.molaya.tests.adapters.in.rest.gen.openapi.dto.ProductDetail; 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 jakarta.validation.constraints.NotEmpty; +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; @@ -21,9 +24,13 @@ public class SimilarProductsController implements ProductGenApi { private final RestSimilarProductsMapper mapper; @Override - @GetMapping("/products/{productId}/similar") + @GetMapping(ProductGenApi.PATH_GET_PRODUCT_SIMILAR) public Mono>> getProductSimilar( - @NotEmpty String productId, ServerWebExchange exchange) { + @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) 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 index 4950589b..1e2ccbf8 100644 --- 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 @@ -5,15 +5,14 @@ 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; -import java.util.Collections; -import java.util.Set; - @Slf4j @Service @RequiredArgsConstructor @@ -25,6 +24,7 @@ public class ClientProductRepositoryAdapter implements ProductRepositoryPort { 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()); } @@ -33,6 +33,7 @@ public class ClientProductRepositoryAdapter implements ProductRepositoryPort { 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/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/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 index ced2a4c5..4ddae07d 100644 --- 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 @@ -1,5 +1,10 @@ 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; @@ -17,11 +22,6 @@ import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Flux; -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; - @WebFluxTest(controllers = SimilarProductsController.class) @Import(AdapterTestApplication.class) class SimilarProductsControllerWebFluxTest { @@ -51,7 +51,7 @@ void returnsSimilarProducts(final String productId) { when(mapper.toRestProductDetail(any())).thenAnswer(callRealMapper()); final var response = webTestClient .get() - .uri("/products/{id}/similar", productId) + .uri("/product/{id}/similar", productId) .exchange() .expectStatus() .isOk() @@ -72,7 +72,7 @@ void returnsEmptyWhenNoProducts(final String productId) { when(useCase.getSimilarProducts(any())).thenReturn(Flux.empty()); final var response = webTestClient .get() - .uri("/products/{id}/similar", productId) + .uri("/product/{id}/similar", productId) .exchange() .expectStatus() .isOk() diff --git a/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java index aff60ae9..ef5d8e94 100644 --- a/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java +++ b/adapters/src/test/java/dev/molaya/tests/adapters/out/client/ClientProductRepositoryAdapterTest.java @@ -1,5 +1,13 @@ package dev.molaya.tests.adapters.out.client; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + import dev.molaya.tests.adapters.Stubs; import dev.molaya.tests.adapters.out.client.gen.openapi.DefaultGenApi; import dev.molaya.tests.application.out.dto.ProductCommand; @@ -13,14 +21,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -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.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class ClientProductRepositoryAdapterTest { @InjectMocks diff --git a/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java b/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java index 5a3e26b4..f8741311 100644 --- a/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java +++ b/application/src/main/java/dev/molaya/tests/application/service/ServiceSimilarProductsMapper.java @@ -4,11 +4,10 @@ import dev.molaya.tests.application.out.dto.ProductCommand; import dev.molaya.tests.domain.exceptions.BadParametersException; import jakarta.validation.constraints.NotNull; +import java.util.Optional; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import java.util.Optional; - @Mapper(componentModel = "spring") public interface ServiceSimilarProductsMapper { @Mapping(target = "id", source = "productId") diff --git a/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java b/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java index e03f2dbb..040f1c09 100644 --- a/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java +++ b/application/src/main/java/dev/molaya/tests/application/service/SimilarProductsService.java @@ -26,7 +26,6 @@ public class SimilarProductsService implements GetSimilarProductsUseCase { @Override public Flux<@NonNull Product> getSimilarProducts(@NotNull GetSimilarProductsInput input) { final var command = mapper.toProductCommandSafe(input); - return Flux.defer(() -> getProductCommandsForIdsSafe(command) .flatMap(port::getProductDetail, properties.parallelRequests())) .doOnError(e -> log.error("Error fetching similar products for product: {}", input.productId(), e)) From f86ff281551772162cd6dde7d045c83e1e65bfc5 Mon Sep 17 00:00:00 2001 From: molaya Date: Fri, 28 Nov 2025 17:48:11 +0100 Subject: [PATCH 9/9] feat(docker): improve docker and documentation to run k6 test --- docker-compose.yaml | 15 ++++++++++++++- readme.md | 8 +++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 2a0470c6..aeed9060 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: "3.3" services: influxdb: image: influxdb:1.8.2 @@ -33,4 +32,18 @@ services: - K6_OUT=influxdb=http://influxdb:8086/k6 extra_hosts: - "host.docker.internal:host-gateway" + back-prod: + build: + context: . + args: + PROJECT_VERSION: 0.0.1-SNAPSHOT + environment: + SPRING_PROFILES_ACTIVE: local + SERVER_PORT: 5000 + ports: + - "5000:5000" + +networks: + default: + driver: "bridge" diff --git a/readme.md b/readme.md index 3a1d9a23..2ce82442 100644 --- a/readme.md +++ b/readme.md @@ -128,14 +128,20 @@ Future improvements and features that could be added to enhance the project and You can run the same test we will put through your application. You just need to have docker installed. First of all, you may need to enable file sharing for the `shared` folder on your docker dashboard -> settings -> resources -> file sharing. +Then execute: + +```bash +mvn clean install +``` Then you can start the mocks and other needed infrastructure with the following command. ``` -docker-compose up -d simulado influxdb grafana +docker-compose up -d simulado influxdb grafana back-prod ``` Check that mocks are working with a sample request to [http://localhost:3001/product/1/similarids](http://localhost:3001/product/1/similarids). +* And verify application is back-prod is up http://localhost:5000/actuator/health To execute the test run: