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:

+ * + * + * @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:

+ * + * + * @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:

+ *
    + *
  1. Se consultan los IDs de productos similares al servicio externo.
  2. + *
  3. Se convierte la lista de IDs en un {@code Flux} para procesarlos como stream.
  4. + *
  5. 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.
  6. + *
  7. 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.
  8. + *
  9. Se recogen todos los resultados en una lista y se emite como {@code Mono}.
  10. + *
+ * + *

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(); + } +}