diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..54b569d4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+similarproducts/target/
+*.class
+.DS_Store
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 2b20a5d9..23b363ee 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -23,6 +23,14 @@ services:
volumes:
- ./shared/simulado:/app
command: ./bin/simulado -f /app/mocks.json
+ similarproducts:
+ build: ./similarproducts
+ ports:
+ - "5000:5000"
+ environment:
+ - PRODUCT_SERVICE_BASE_URL=http://simulado
+ depends_on:
+ - simulado
k6:
image: loadimpact/k6:0.28.0
ports:
diff --git a/similarproducts/.dockerignore b/similarproducts/.dockerignore
new file mode 100644
index 00000000..04727b81
--- /dev/null
+++ b/similarproducts/.dockerignore
@@ -0,0 +1,5 @@
+target/
+*.class
+.git
+.gitignore
+*.md
diff --git a/similarproducts/.gitignore b/similarproducts/.gitignore
new file mode 100644
index 00000000..c6679064
--- /dev/null
+++ b/similarproducts/.gitignore
@@ -0,0 +1,17 @@
+HELP.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### VS Code ###
+.vscode/
+
+### OS ###
+.DS_Store
diff --git a/similarproducts/Dockerfile b/similarproducts/Dockerfile
new file mode 100644
index 00000000..298ccab5
--- /dev/null
+++ b/similarproducts/Dockerfile
@@ -0,0 +1,17 @@
+# ---- Stage 1: Build ----
+FROM maven:3.9-eclipse-temurin-21 AS build
+WORKDIR /app
+
+# Cache dependencies layer separately from source code
+COPY pom.xml .
+RUN mvn dependency:go-offline -q
+
+COPY src ./src
+RUN mvn package -q
+
+# ---- Stage 2: Runtime ----
+FROM eclipse-temurin:21-jre
+WORKDIR /app
+COPY --from=build /app/target/*.jar app.jar
+EXPOSE 5000
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/similarproducts/pom.xml b/similarproducts/pom.xml
new file mode 100644
index 00000000..8f9a5f81
--- /dev/null
+++ b/similarproducts/pom.xml
@@ -0,0 +1,54 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.knowmad
+ similar-products
+ 0.0.1-SNAPSHOT
+ similar-products
+ Similar Products API - Backend Dev Test
+
+
+ 21
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/SimilarProductsApplication.java b/similarproducts/src/main/java/com/knowmad/similarproducts/SimilarProductsApplication.java
new file mode 100644
index 00000000..95f0092f
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/SimilarProductsApplication.java
@@ -0,0 +1,30 @@
+package com.knowmad.similarproducts;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Punto de entrada de la aplicación Similar Products API.
+ *
+ *
Aplicación Spring Boot reactiva (WebFlux) que expone el endpoint
+ * {@code GET /product/{productId}/similar} en el puerto 5000.
+ *
+ * La aplicación actúa como agregador: recibe una petición de productos
+ * similares, consulta el servicio externo para obtener los IDs similares
+ * y luego recupera el detalle de cada uno en paralelo, devolviendo la
+ * lista completa al cliente de forma eficiente y resiliente.
+ *
+ * Configuración principal en {@code src/main/resources/application.yml}.
+ */
+@SpringBootApplication
+public class SimilarProductsApplication {
+
+ /**
+ * Método principal que inicia el contexto de Spring Boot.
+ *
+ * @param args argumentos de línea de comandos (no utilizados)
+ */
+ public static void main(String[] args) {
+ SpringApplication.run(SimilarProductsApplication.class, args);
+ }
+}
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/client/ProductClient.java b/similarproducts/src/main/java/com/knowmad/similarproducts/client/ProductClient.java
new file mode 100644
index 00000000..1a1b8665
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/client/ProductClient.java
@@ -0,0 +1,157 @@
+package com.knowmad.similarproducts.client;
+
+import com.knowmad.similarproducts.model.ProductDetail;
+import com.knowmad.similarproducts.model.ProductNotFoundException;
+import io.netty.channel.ChannelOption;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+import reactor.netty.http.client.HttpClient;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Cliente HTTP reactivo para comunicarse con el servicio externo de productos.
+ *
+ * Encapsula todas las llamadas al API externo ({@code localhost:3001} por defecto)
+ * usando {@link WebClient} de Spring WebFlux sobre Netty. Gestiona dos tipos de
+ * llamadas: obtención de IDs similares y obtención de detalle de producto.
+ *
+ * Los timeouts se configuran a nivel de conexión TCP y de respuesta HTTP
+ * directamente en el cliente Netty subyacente, garantizando que ninguna llamada
+ * quede bloqueada indefinidamente.
+ *
+ * Configuración relevante en {@code application.yml}:
+ *
+ * product.service.base-url → URL base del servicio externo
+ * product.service.timeout-ms → timeout de respuesta en milisegundos (defecto: 2000)
+ *
+ */
+@Component
+public class ProductClient {
+
+ private static final Logger log = LoggerFactory.getLogger(ProductClient.class);
+
+ private final WebClient webClient;
+
+ /**
+ * Construye el cliente HTTP configurando los timeouts de red.
+ *
+ * Se configuran dos niveles de timeout:
+ *
+ * Timeout de conexión TCP (fijo a 1 s): tiempo máximo para
+ * establecer la conexión con el servidor remoto.
+ * Timeout de respuesta HTTP (configurable): tiempo máximo desde
+ * que se envía la petición hasta que se reciben las cabeceras de respuesta.
+ * Por defecto 2 000 ms, ajustable con {@code product.service.timeout-ms}.
+ *
+ *
+ * @param baseUrl URL base del servicio externo (p.ej. {@code http://localhost:3001})
+ * @param timeoutMs tiempo máximo de espera de respuesta en milisegundos
+ */
+ public ProductClient(
+ @Value("${product.service.base-url}") String baseUrl,
+ @Value("${product.service.timeout-ms:2000}") long timeoutMs) {
+
+ // Configuramos el cliente Netty con timeouts explícitos para evitar que
+ // peticiones a productos lentos (como product/1000 con 5 s de delay)
+ // bloqueen el pool de conexiones bajo alta concurrencia.
+ HttpClient httpClient = HttpClient.create()
+ .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
+ .responseTimeout(Duration.ofMillis(timeoutMs));
+
+ this.webClient = WebClient.builder()
+ .baseUrl(baseUrl)
+ .clientConnector(new ReactorClientHttpConnector(httpClient))
+ .build();
+ }
+
+ /**
+ * Obtiene la lista de identificadores de productos similares para un producto dado.
+ *
+ * Realiza una llamada GET a {@code /product/{id}/similarids} en el servicio externo.
+ * Los IDs devueltos pueden ser números enteros en el JSON (p.ej. {@code [2, 3, 4]}),
+ * por lo que se deserializan como {@code List} y se convierten a {@code String}
+ * para uniformidad con el resto del dominio.
+ *
+ * Comportamiento ante errores:
+ *
+ * 404 : lanza {@link ProductNotFoundException} para que el controlador
+ * devuelva un HTTP 404 al cliente.
+ * Timeout / red : el error se propaga hacia arriba sin consumir;
+ * el controlador lo trata como error de servidor (5xx).
+ *
+ *
+ * @param productId identificador del producto del que se quieren obtener similares
+ * @return {@code Mono} que emite la lista ordenada de IDs similares,
+ * o completa con error si el producto no existe
+ */
+ public Mono> getSimilarIds(String productId) {
+ return webClient.get()
+ .uri("/product/{id}/similarids", productId)
+ .retrieve()
+ // Si el servicio externo responde con 404, convertimos el error a nuestra
+ // excepción de dominio para que el controlador pueda distinguirlo.
+ .onStatus(
+ status -> status.value() == 404,
+ response -> {
+ log.warn("Producto no encontrado al consultar similarids: productId={}", productId);
+ return Mono.error(new ProductNotFoundException(productId));
+ })
+ // Deserializamos como List porque el servicio externo devuelve
+ // números (e.g. [2,3,4]) en lugar de strings. Luego los convertimos a String.
+ .bodyToMono(new ParameterizedTypeReference>() {})
+ .map(list -> list.stream()
+ .map(Object::toString)
+ .collect(Collectors.toList()));
+ }
+
+ /**
+ * Obtiene el detalle completo de un producto a partir de su identificador.
+ *
+ * Realiza una llamada GET a {@code /product/{id}} en el servicio externo.
+ * Este método está diseñado para ser invocado en paralelo para múltiples IDs,
+ * por lo que ante cualquier problema simplemente omite el producto afectado
+ * en lugar de fallar toda la operación.
+ *
+ * Comportamiento ante errores (todos resultan en omitir el producto):
+ *
+ * 404 : el producto no existe en el catálogo, se descarta.
+ * 5xx : error interno del servicio externo, se descarta.
+ * Timeout : el producto tarda más del límite configurado
+ * ({@code product.service.timeout-ms}), se descarta.
+ * Error de red : fallo de conectividad, se descarta.
+ *
+ *
+ * @param productId identificador del producto a consultar
+ * @return {@code Mono} que emite el {@link ProductDetail} si la llamada tiene éxito,
+ * o {@code Mono.empty()} si ocurre cualquier error (el producto se omite)
+ */
+ public Mono getProductDetail(String productId) {
+ return webClient.get()
+ .uri("/product/{id}", productId)
+ .retrieve()
+ // Convertimos cualquier respuesta de error (4xx o 5xx) en una excepción
+ // para poder capturarla de forma uniforme en el onErrorResume siguiente.
+ .onStatus(
+ status -> status.isError(),
+ response -> Mono.error(new RuntimeException("Error fetching product: " + productId)))
+ .bodyToMono(ProductDetail.class)
+ // Estrategia de resiliencia: ante cualquier error (incluido timeout de Netty,
+ // error de red o excepción de deserialización) devolvemos Mono.empty().
+ // Esto hace que flatMapSequential en el servicio simplemente ignore este
+ // producto sin interrumpir la respuesta al cliente.
+ .onErrorResume(e -> {
+ log.warn("Producto descartado (error o timeout): productId={}, causa={}", productId, e.getMessage());
+ return Mono.empty();
+ });
+ }
+}
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/controller/GlobalExceptionHandler.java b/similarproducts/src/main/java/com/knowmad/similarproducts/controller/GlobalExceptionHandler.java
new file mode 100644
index 00000000..f303307f
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/controller/GlobalExceptionHandler.java
@@ -0,0 +1,41 @@
+package com.knowmad.similarproducts.controller;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Map;
+
+/**
+ * Manejador global de excepciones no controladas para la capa REST.
+ *
+ * Captura cualquier excepción que escape de los controladores sin ser gestionada
+ * explícitamente (p.ej. errores de red en {@code getSimilarIds} que no son 404)
+ * y devuelve una respuesta JSON con código HTTP 500 en lugar del HTML de error
+ * por defecto de Spring.
+ *
+ * Las excepciones de dominio conocidas (como {@link com.knowmad.similarproducts.model.ProductNotFoundException})
+ * son manejadas directamente en el controlador mediante {@code onErrorResume} y
+ * nunca llegan a este handler.
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ /**
+ * Maneja cualquier excepción inesperada que no haya sido capturada previamente.
+ *
+ * @param ex la excepción no controlada
+ * @return mapa con el mensaje de error en formato JSON
+ */
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public Map handleUnexpectedException(Exception ex) {
+ log.error("Error inesperado no controlado: {}", ex.getMessage(), ex);
+ return Map.of("error", "Se ha producido un error interno. Por favor, inténtelo de nuevo más tarde.");
+ }
+}
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/controller/SimilarProductsController.java b/similarproducts/src/main/java/com/knowmad/similarproducts/controller/SimilarProductsController.java
new file mode 100644
index 00000000..b34afc2d
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/controller/SimilarProductsController.java
@@ -0,0 +1,79 @@
+package com.knowmad.similarproducts.controller;
+
+import com.knowmad.similarproducts.model.ProductDetail;
+import com.knowmad.similarproducts.model.ProductNotFoundException;
+import com.knowmad.similarproducts.service.SimilarProductsService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * Controlador REST que expone el endpoint de productos similares.
+ *
+ * Implementa el contrato definido en {@code similarProducts.yaml}:
+ * {@code GET /product/{productId}/similar}.
+ *
+ * Este controlador es reactivo (Spring WebFlux): no bloquea ningún hilo
+ * mientras espera respuestas del servicio externo, lo que permite gestionar
+ * un alto número de peticiones concurrentes con muy pocos recursos.
+ */
+@RestController
+public class SimilarProductsController {
+
+ private static final Logger log = LoggerFactory.getLogger(SimilarProductsController.class);
+
+ private final SimilarProductsService similarProductsService;
+
+ /**
+ * Constructor con inyección de dependencias del servicio de negocio.
+ *
+ * @param similarProductsService servicio que orquesta la lógica de productos similares
+ */
+ public SimilarProductsController(SimilarProductsService similarProductsService) {
+ this.similarProductsService = similarProductsService;
+ }
+
+ /**
+ * Devuelve la lista de productos similares al producto indicado.
+ *
+ * Endpoint: {@code GET /product/{productId}/similar}
+ *
+ * Respuestas posibles según el contrato {@code similarProducts.yaml}:
+ *
+ * 200 OK : lista de {@link ProductDetail} ordenada por similitud.
+ * Puede ser una lista vacía si todos los productos similares fallaron
+ * (404, 5xx o timeout en el servicio externo).
+ * 404 Not Found : el producto raíz ({@code productId}) no existe
+ * en el catálogo (el servicio externo devolvió 404 al consultar sus similares).
+ *
+ *
+ * @param productId identificador del producto del que se quieren obtener similares
+ * @return {@code Mono} con el {@link ResponseEntity} apropiado:
+ * 200 con la lista de productos, o 404 si el producto no existe
+ */
+ @GetMapping("/product/{productId}/similar")
+ public Mono>> getSimilarProducts(
+ @PathVariable String productId) {
+ log.info("Solicitando productos similares para productId={}", productId);
+ return similarProductsService.getSimilarProducts(productId)
+ // Si el servicio completa correctamente, envolvemos la lista en un 200 OK.
+ .map(products -> {
+ log.info("Devolviendo {} productos similares para productId={}", products.size(), productId);
+ return ResponseEntity.ok(products);
+ })
+ // Si el servicio lanza ProductNotFoundException (producto raíz no encontrado),
+ // devolvemos 404. El resto de errores se dejan escalar como 5xx.
+ .onErrorResume(
+ ProductNotFoundException.class,
+ e -> {
+ log.warn("Producto no encontrado: productId={}", productId);
+ return Mono.just(ResponseEntity.notFound().build());
+ });
+ }
+}
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/model/ProductDetail.java b/similarproducts/src/main/java/com/knowmad/similarproducts/model/ProductDetail.java
new file mode 100644
index 00000000..43738f66
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/model/ProductDetail.java
@@ -0,0 +1,23 @@
+package com.knowmad.similarproducts.model;
+
+/**
+ * Representa el detalle completo de un producto del catálogo.
+ *
+ * Modelado como {@code record} de Java para ser inmutable y conciso.
+ * Los campos se corresponden directamente con el esquema {@code ProductDetail}
+ * definido en {@code similarProducts.yaml} y {@code existingApis.yaml}.
+ *
+ * Jackson (incluido en Spring WebFlux) deserializa automáticamente el JSON
+ * del servicio externo en esta clase usando los nombres de campo como claves.
+ *
+ * @param id identificador único del producto (p.ej. {@code "1"})
+ * @param name nombre del producto (p.ej. {@code "Shirt"})
+ * @param price precio del producto (p.ej. {@code 9.99})
+ * @param availability {@code true} si el producto está disponible, {@code false} si no
+ */
+public record ProductDetail(
+ String id,
+ String name,
+ Double price,
+ Boolean availability
+) {}
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/model/ProductNotFoundException.java b/similarproducts/src/main/java/com/knowmad/similarproducts/model/ProductNotFoundException.java
new file mode 100644
index 00000000..ccfd2d81
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/model/ProductNotFoundException.java
@@ -0,0 +1,23 @@
+package com.knowmad.similarproducts.model;
+
+/**
+ * Excepción de dominio que indica que un producto no existe en el catálogo.
+ *
+ * Se lanza cuando el servicio externo responde con HTTP 404 al consultar
+ * los IDs similares de un producto. El controlador la captura para devolver
+ * una respuesta HTTP 404 al cliente, conforme al contrato de la API.
+ *
+ * Al extender {@link RuntimeException} no es necesario declararla en las
+ * firmas de los métodos reactivos, lo que simplifica la cadena de operadores.
+ */
+public class ProductNotFoundException extends RuntimeException {
+
+ /**
+ * Crea la excepción con un mensaje descriptivo que incluye el ID del producto.
+ *
+ * @param productId identificador del producto que no se ha encontrado
+ */
+ public ProductNotFoundException(String productId) {
+ super("Product not found: " + productId);
+ }
+}
diff --git a/similarproducts/src/main/java/com/knowmad/similarproducts/service/SimilarProductsService.java b/similarproducts/src/main/java/com/knowmad/similarproducts/service/SimilarProductsService.java
new file mode 100644
index 00000000..24088d1f
--- /dev/null
+++ b/similarproducts/src/main/java/com/knowmad/similarproducts/service/SimilarProductsService.java
@@ -0,0 +1,70 @@
+package com.knowmad.similarproducts.service;
+
+import com.knowmad.similarproducts.client.ProductClient;
+import com.knowmad.similarproducts.model.ProductDetail;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * Servicio que orquesta la lógica de negocio para obtener los productos similares.
+ *
+ * Actúa como capa intermedia entre el controlador REST y el cliente HTTP externo.
+ * Su responsabilidad es coordinar las llamadas al API externo de forma reactiva
+ * y eficiente, delegando en {@link ProductClient} los detalles de comunicación.
+ */
+@Service
+public class SimilarProductsService {
+
+ private final ProductClient productClient;
+
+ /**
+ * Constructor con inyección de dependencias del cliente HTTP.
+ *
+ * @param productClient cliente reactivo para llamar al servicio externo de productos
+ */
+ public SimilarProductsService(ProductClient productClient) {
+ this.productClient = productClient;
+ }
+
+ /**
+ * Obtiene los detalles de los productos similares a uno dado.
+ *
+ * El flujo de ejecución es el siguiente:
+ *
+ * Se consultan los IDs de productos similares al servicio externo.
+ * Se convierte la lista de IDs en un {@code Flux} para procesarlos como stream.
+ * Por cada ID se lanza una petición de detalle de producto de forma concurrente
+ * usando {@code flatMapSequential}: todas las peticiones HTTP se ejecutan en
+ * paralelo pero los resultados se emiten en el mismo orden que los IDs originales,
+ * preservando la ordenación por similitud.
+ * Los productos que fallen (404, 5xx, timeout) son ignorados silenciosamente
+ * porque {@link ProductClient#getProductDetail} devuelve {@code Mono.empty()}
+ * en caso de error, y {@code flatMapSequential} omite los vacíos.
+ * Se recogen todos los resultados en una lista y se emite como {@code Mono}.
+ *
+ *
+ * Nota sobre rendimiento: {@code flatMapSequential} lanza hasta 256 peticiones
+ * concurrentes por defecto (configurable), lo que lo hace mucho más eficiente que
+ * un bucle secuencial cuando hay múltiples IDs similares.
+ *
+ * @param productId identificador del producto del que se quieren obtener los similares
+ * @return {@code Mono} que emite la lista de {@link ProductDetail} de los productos
+ * similares disponibles (puede ser vacía si ninguno responde correctamente),
+ * o completa con {@link ProductNotFoundException} si el producto raíz no existe
+ */
+ public Mono> getSimilarProducts(String productId) {
+ return productClient.getSimilarIds(productId)
+ // Convertimos la lista de IDs en un Flux para poder operar sobre
+ // cada elemento de forma reactiva y concurrente.
+ .flatMapMany(Flux::fromIterable)
+ // flatMapSequential: lanza todas las llamadas HTTP en paralelo
+ // pero mantiene el orden de emisión igual al de los IDs recibidos.
+ // Los Mono.empty() devueltos por errores se descartan automáticamente.
+ .flatMapSequential(productClient::getProductDetail)
+ // Recogemos todos los resultados en una lista para devolver al controlador.
+ .collectList();
+ }
+}
diff --git a/similarproducts/src/main/resources/application.yml b/similarproducts/src/main/resources/application.yml
new file mode 100644
index 00000000..e2f5c80d
--- /dev/null
+++ b/similarproducts/src/main/resources/application.yml
@@ -0,0 +1,11 @@
+server:
+ port: 5000
+
+product:
+ service:
+ base-url: http://localhost:3001
+ timeout-ms: 2000
+
+spring:
+ application:
+ name: similar-products
diff --git a/similarproducts/src/test/java/com/knowmad/similarproducts/controller/SimilarProductsControllerTest.java b/similarproducts/src/test/java/com/knowmad/similarproducts/controller/SimilarProductsControllerTest.java
new file mode 100644
index 00000000..84473e46
--- /dev/null
+++ b/similarproducts/src/test/java/com/knowmad/similarproducts/controller/SimilarProductsControllerTest.java
@@ -0,0 +1,87 @@
+package com.knowmad.similarproducts.controller;
+
+import com.knowmad.similarproducts.model.ProductDetail;
+import com.knowmad.similarproducts.model.ProductNotFoundException;
+import com.knowmad.similarproducts.service.SimilarProductsService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests unitarios del controlador REST {@link SimilarProductsController}.
+ *
+ * Usa {@code @WebFluxTest} para cargar únicamente la capa web (controladores y
+ * manejadores de excepción) sin levantar el contexto completo de la aplicación.
+ * El servicio se mockea con {@code @MockBean} para aislar el comportamiento HTTP.
+ */
+@WebFluxTest(SimilarProductsController.class)
+@DisplayName("SimilarProductsController")
+class SimilarProductsControllerTest {
+
+ @Autowired
+ private WebTestClient webTestClient;
+
+ @MockBean
+ private SimilarProductsService similarProductsService;
+
+ @Test
+ @DisplayName("GET /product/{id}/similar devuelve 200 con la lista de productos similares")
+ void getSimilarProducts_devuelve200ConListaDeSimilares() {
+ List productos = List.of(
+ new ProductDetail("2", "Dress", 19.99, true),
+ new ProductDetail("3", "Blazer", 29.99, false));
+ when(similarProductsService.getSimilarProducts("1")).thenReturn(Mono.just(productos));
+
+ webTestClient.get()
+ .uri("/product/1/similar")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBodyList(ProductDetail.class)
+ .hasSize(2);
+ }
+
+ @Test
+ @DisplayName("GET /product/{id}/similar devuelve 404 cuando el producto raíz no existe")
+ void getSimilarProducts_devuelve404CuandoProductoNoExiste() {
+ when(similarProductsService.getSimilarProducts("999"))
+ .thenReturn(Mono.error(new ProductNotFoundException("999")));
+
+ webTestClient.get()
+ .uri("/product/999/similar")
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ @DisplayName("GET /product/{id}/similar devuelve 200 con lista vacía si no hay similares")
+ void getSimilarProducts_devuelve200ConListaVaciaSiNoHaySimilares() {
+ when(similarProductsService.getSimilarProducts("1")).thenReturn(Mono.just(List.of()));
+
+ webTestClient.get()
+ .uri("/product/1/similar")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBodyList(ProductDetail.class)
+ .hasSize(0);
+ }
+
+ @Test
+ @DisplayName("GET /product/{id}/similar devuelve 500 ante error inesperado no controlado")
+ void getSimilarProducts_devuelve500AnteErrorInesperado() {
+ when(similarProductsService.getSimilarProducts("1"))
+ .thenReturn(Mono.error(new RuntimeException("error inesperado")));
+
+ webTestClient.get()
+ .uri("/product/1/similar")
+ .exchange()
+ .expectStatus().is5xxServerError();
+ }
+}
diff --git a/similarproducts/src/test/java/com/knowmad/similarproducts/service/SimilarProductsServiceTest.java b/similarproducts/src/test/java/com/knowmad/similarproducts/service/SimilarProductsServiceTest.java
new file mode 100644
index 00000000..22afd67d
--- /dev/null
+++ b/similarproducts/src/test/java/com/knowmad/similarproducts/service/SimilarProductsServiceTest.java
@@ -0,0 +1,105 @@
+package com.knowmad.similarproducts.service;
+
+import com.knowmad.similarproducts.client.ProductClient;
+import com.knowmad.similarproducts.model.ProductDetail;
+import com.knowmad.similarproducts.model.ProductNotFoundException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests unitarios de {@link SimilarProductsService}.
+ *
+ * Se mockea {@link ProductClient} para aislar la lógica de negocio del servicio
+ * sin depender de llamadas HTTP reales. Se usa {@link StepVerifier} de Reactor Test
+ * para verificar el comportamiento de los flujos reactivos.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("SimilarProductsService")
+class SimilarProductsServiceTest {
+
+ @Mock
+ private ProductClient productClient;
+
+ @InjectMocks
+ private SimilarProductsService service;
+
+ @Test
+ @DisplayName("devuelve la lista ordenada cuando todos los productos responden correctamente")
+ void getSimilarProducts_devuelveListaOrdenadaCuandoTodoVaBien() {
+ ProductDetail dress = new ProductDetail("2", "Dress", 19.99, true);
+ ProductDetail blazer = new ProductDetail("3", "Blazer", 29.99, false);
+
+ when(productClient.getSimilarIds("1")).thenReturn(Mono.just(List.of("2", "3")));
+ when(productClient.getProductDetail("2")).thenReturn(Mono.just(dress));
+ when(productClient.getProductDetail("3")).thenReturn(Mono.just(blazer));
+
+ StepVerifier.create(service.getSimilarProducts("1"))
+ .assertNext(list -> {
+ assertThat(list).hasSize(2);
+ assertThat(list.get(0).id()).isEqualTo("2");
+ assertThat(list.get(1).id()).isEqualTo("3");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ @DisplayName("omite los productos que devuelven error o timeout sin romper la respuesta")
+ void getSimilarProducts_omiteProductosConError() {
+ ProductDetail dress = new ProductDetail("2", "Dress", 19.99, true);
+
+ when(productClient.getSimilarIds("1")).thenReturn(Mono.just(List.of("2", "3")));
+ when(productClient.getProductDetail("2")).thenReturn(Mono.just(dress));
+ when(productClient.getProductDetail("3")).thenReturn(Mono.empty()); // simula 404/500/timeout
+
+ StepVerifier.create(service.getSimilarProducts("1"))
+ .assertNext(list -> {
+ assertThat(list).hasSize(1);
+ assertThat(list.get(0).id()).isEqualTo("2");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ @DisplayName("propaga ProductNotFoundException cuando el producto raíz no existe")
+ void getSimilarProducts_propagaProductoNotFoundException() {
+ when(productClient.getSimilarIds("999"))
+ .thenReturn(Mono.error(new ProductNotFoundException("999")));
+
+ StepVerifier.create(service.getSimilarProducts("999"))
+ .expectError(ProductNotFoundException.class)
+ .verify();
+ }
+
+ @Test
+ @DisplayName("devuelve lista vacía cuando el producto no tiene similares")
+ void getSimilarProducts_devuelveListaVaciaSiNoHaySimilares() {
+ when(productClient.getSimilarIds("1")).thenReturn(Mono.just(List.of()));
+
+ StepVerifier.create(service.getSimilarProducts("1"))
+ .assertNext(list -> assertThat(list).isEmpty())
+ .verifyComplete();
+ }
+
+ @Test
+ @DisplayName("devuelve lista vacía cuando todos los productos similares fallan")
+ void getSimilarProducts_devuelveListaVaciaCuandoTodosLosProductosFallan() {
+ when(productClient.getSimilarIds("1")).thenReturn(Mono.just(List.of("2", "3")));
+ when(productClient.getProductDetail("2")).thenReturn(Mono.empty());
+ when(productClient.getProductDetail("3")).thenReturn(Mono.empty());
+
+ StepVerifier.create(service.getSimilarProducts("1"))
+ .assertNext(list -> assertThat(list).isEmpty())
+ .verifyComplete();
+ }
+}