From 570ea90e68dd783468556998f1257b847a572923 Mon Sep 17 00:00:00 2001 From: Jesus Navalon Date: Sat, 11 Jul 2026 21:46:22 +0200 Subject: [PATCH] Tests + fixes --- pom.xml | 7 + .../productaffinity/domain/model/Product.java | 2 +- .../incoming/rest/ErrorExceptionHandler.java | 23 +++ .../incoming/rest/ProductController.java | 35 ++++ .../client/ApiProductDetailAdapter.java | 58 ++++++- .../rest/AffinityProductsControllerTest.java | 79 +++++++++ .../client/ApiAffinityIdsAdapterTest.java | 142 ++++++++++++++++ .../client/ApiProductDetailAdapterTest.java | 158 ++++++++++++++++++ 8 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ErrorExceptionHandler.java create mode 100644 src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ProductController.java create mode 100644 src/test/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/AffinityProductsControllerTest.java create mode 100644 src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiAffinityIdsAdapterTest.java create mode 100644 src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiProductDetailAdapterTest.java diff --git a/pom.xml b/pom.xml index bdc6f06..486d567 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ 21 2.3.0 + 3.13.1 @@ -55,6 +56,12 @@ reactor-test test + + org.wiremock + wiremock-standalone + ${wiremock.version} + test + diff --git a/src/main/java/com/example/productaffinity/domain/model/Product.java b/src/main/java/com/example/productaffinity/domain/model/Product.java index c0a5c54..a37fa04 100644 --- a/src/main/java/com/example/productaffinity/domain/model/Product.java +++ b/src/main/java/com/example/productaffinity/domain/model/Product.java @@ -3,5 +3,5 @@ package com.example.productaffinity.domain.model; import java.math.BigDecimal; // Represents a product detail -public record Product(String id, String name, BigDecimal price, boolean availability) { +public record Product(String id, String name, BigDecimal price, Boolean availability) { } \ No newline at end of file diff --git a/src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ErrorExceptionHandler.java b/src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ErrorExceptionHandler.java new file mode 100644 index 0000000..9bf1402 --- /dev/null +++ b/src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ErrorExceptionHandler.java @@ -0,0 +1,23 @@ +package com.example.productaffinity.infraestructure.adapter.incoming.rest; + +import com.example.productaffinity.domain.exception.ExternalServiceException; +import com.example.productaffinity.domain.exception.ProductNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import reactor.core.publisher.Mono; + +@RestControllerAdvice +public class ErrorExceptionHandler { + + @ExceptionHandler(ProductNotFoundException.class) + public Mono> handleNotFound(ProductNotFoundException ex) { + return Mono.just(ResponseEntity.status(HttpStatus.NOT_FOUND).build()); + } + + @ExceptionHandler(ExternalServiceException.class) + public Mono> handleExternalServiceFailure(ExternalServiceException ex) { + return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build()); + } +} diff --git a/src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ProductController.java b/src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ProductController.java new file mode 100644 index 0000000..4711716 --- /dev/null +++ b/src/main/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/ProductController.java @@ -0,0 +1,35 @@ +package com.example.productaffinity.infraestructure.adapter.incoming.rest; + +import com.example.productaffinity.domain.model.Product; +import com.example.productaffinity.domain.port.incomming.GetAffinityProductsUC; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RestController +public class ProductController { + private final GetAffinityProductsUC getAffinityProductsUC; + + public ProductController(final GetAffinityProductsUC getAffinityProductsUC) { + this.getAffinityProductsUC = getAffinityProductsUC; + } + + @RequestMapping( + method = RequestMethod.GET, + value = "/product/{productId}/similar", + produces = { "application/json" } + ) + public Mono>> getProductSimilar(@PathVariable final String productId) { + return getAffinityProductsUC.getAffinityProducts(productId) + .map(products -> ResponseEntity.ok(Flux.fromIterable(products).map(this::toApiModel))); + } + + private Product toApiModel(Product product) { + return new Product(product.id(), product.name(), product.price(), product.availability()); + } +} diff --git a/src/main/java/com/example/productaffinity/infraestructure/adapter/outcomming/client/ApiProductDetailAdapter.java b/src/main/java/com/example/productaffinity/infraestructure/adapter/outcomming/client/ApiProductDetailAdapter.java index 5d59255..7ac8b26 100644 --- a/src/main/java/com/example/productaffinity/infraestructure/adapter/outcomming/client/ApiProductDetailAdapter.java +++ b/src/main/java/com/example/productaffinity/infraestructure/adapter/outcomming/client/ApiProductDetailAdapter.java @@ -1,22 +1,35 @@ package com.example.productaffinity.infraestructure.adapter.outcomming.client; +import com.example.productaffinity.domain.exception.ProductDetailUnavailableException; import com.example.productaffinity.domain.model.Product; import com.example.productaffinity.domain.port.outcoming.ProductDetailsPort; import io.github.resilience4j.bulkhead.Bulkhead; +import io.github.resilience4j.bulkhead.BulkheadFullException; import io.github.resilience4j.bulkhead.BulkheadRegistry; +import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator; +import io.github.resilience4j.reactor.retry.RetryOperator; +import io.github.resilience4j.reactor.timelimiter.TimeLimiterOperator; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryRegistry; import io.github.resilience4j.timelimiter.TimeLimiter; import io.github.resilience4j.timelimiter.TimeLimiterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.codec.DecodingException; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; +import java.util.concurrent.TimeoutException; + @Component public class ApiProductDetailAdapter implements ProductDetailsPort { private static final String RESILIENCE_INSTANCE = "productDetail"; + private static final Logger log = LoggerFactory.getLogger(ApiProductDetailAdapter.class); private final WebClient webClient; private final TimeLimiter timeLimiter; @@ -39,7 +52,48 @@ public class ApiProductDetailAdapter implements ProductDetailsPort { } @Override - public Mono findProductDetail(final String productId) { - return null; + public Mono findProductDetail(String productId) { + return requestCoalescer.execute(productId, () -> fetchProductDetail(productId)); + } + + public Mono fetchProductDetail(final String productId) { + Mono call = webClient.get() + .uri("/product/{productId}", productId) + .retrieve() + .bodyToMono(Product.class) + .switchIfEmpty(Mono.error(new DecodingException("Empty product detail response"))) + .map(detail -> toProduct(productId, detail)); + + return call.transformDeferred(TimeLimiterOperator.of(timeLimiter)) + .transformDeferred(BulkheadOperator.of(bulkhead)) + .transformDeferred(RetryOperator.of(retry)) + .onErrorMap(this::isExpectedDependencyFailure, + error -> new ProductDetailUnavailableException(productId, error)) + .doOnError(ProductDetailUnavailableException.class, this::logUnavailableProduct); + } + + + private Product toProduct(String requestedProductId, Product product) { + if (product.id() == null || product.id().isBlank() + || product.name() == null || product.name().isBlank() + || product.price() == null || product.availability() == null + || !requestedProductId.equals(product.id())) { + throw new DecodingException("Product detail response violates the external API contract"); + } + return new Product(product.id(), product.name(), product.price(), product.availability()); + } + + private boolean isExpectedDependencyFailure(Throwable error) { + return error instanceof WebClientResponseException + || error instanceof WebClientRequestException + || error instanceof TimeoutException + || error instanceof DecodingException + || error instanceof BulkheadFullException; + } + + private void logUnavailableProduct(ProductDetailUnavailableException error) { + Throwable cause = error.getCause(); + log.warn("product_detail_unavailable productId={} cause={} message={}", + error.productId(), cause.getClass().getSimpleName(), cause.getMessage()); } } diff --git a/src/test/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/AffinityProductsControllerTest.java b/src/test/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/AffinityProductsControllerTest.java new file mode 100644 index 0000000..026091f --- /dev/null +++ b/src/test/java/com/example/productaffinity/infraestructure/adapter/incoming/rest/AffinityProductsControllerTest.java @@ -0,0 +1,79 @@ +package com.example.productaffinity.infraestructure.adapter.incoming.rest; + +import com.example.productaffinity.domain.exception.ExternalServiceException; +import com.example.productaffinity.domain.exception.ProductNotFoundException; +import com.example.productaffinity.domain.model.Product; +import com.example.productaffinity.domain.port.incomming.GetAffinityProductsUC; +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.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.List; + +import static org.mockito.Mockito.when; + +@WebFluxTest(controllers = ProductController.class) +class AffinityProductsControllerTest { + + @Autowired + private WebTestClient webTestClient; + + @MockitoBean + private GetAffinityProductsUC getAffinityProductsUC; + + @Test + void returnsSimilarProductsAsJsonArray() { + when(getAffinityProductsUC.getAffinityProducts("1")).thenReturn(Mono.just(List.of( + new Product("2", "Dress", new BigDecimal("19.99"), true), + new Product("3", "Blazer", new BigDecimal("29.99"), false)))); + + webTestClient.get().uri("/product/1/similar") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.length()").isEqualTo(2) + .jsonPath("$[0].id").isEqualTo("2") + .jsonPath("$[0].name").isEqualTo("Dress") + .jsonPath("$[0].price").isEqualTo(19.99) + .jsonPath("$[0].availability").isEqualTo(true) + .jsonPath("$[1].id").isEqualTo("3"); + } + + @Test + void returnsNotFoundWhenProductDoesNotExist() { + when(getAffinityProductsUC.getAffinityProducts("999")) + .thenReturn(Mono.error(new ProductNotFoundException("999"))); + + webTestClient.get().uri("/product/999/similar") + .exchange() + .expectStatus().isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void returnsServiceUnavailableWhenExternalDependencyFails() { + when(getAffinityProductsUC.getAffinityProducts("1")) + .thenReturn(Mono.error(new ExternalServiceException("boom", new RuntimeException()))); + + webTestClient.get().uri("/product/1/similar") + .exchange() + .expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + void returnsEmptyArrayWhenNoSimilarProductResolved() { + when(getAffinityProductsUC.getAffinityProducts("4")).thenReturn(Mono.just(List.of())); + + webTestClient.get().uri("/product/4/similar") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.length()").isEqualTo(0); + } +} diff --git a/src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiAffinityIdsAdapterTest.java b/src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiAffinityIdsAdapterTest.java new file mode 100644 index 0000000..fa00893 --- /dev/null +++ b/src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiAffinityIdsAdapterTest.java @@ -0,0 +1,142 @@ +package com.example.productaffinity.infraestructure.adapter.outcoming.client; + +import com.example.productaffinity.domain.exception.ExternalServiceException; +import com.example.productaffinity.domain.exception.ProductNotFoundException; +import com.example.productaffinity.infraestructure.adapter.outcomming.client.ApiAffinityIdsAdapter; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import io.github.resilience4j.bulkhead.BulkheadConfig; +import io.github.resilience4j.bulkhead.BulkheadRegistry; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.github.resilience4j.timelimiter.TimeLimiterConfig; +import io.github.resilience4j.timelimiter.TimeLimiterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.notFound; +import static com.github.tomakehurst.wiremock.client.WireMock.serverError; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApiAffinityIdsAdapterTest { + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance().build(); + + private final static String PRODUCT_ENDPOINT = "/product"; + private ApiAffinityIdsAdapter adapter; + + @BeforeEach + void init() { + WebClient webClient = WebClient.builder().baseUrl(wireMock.baseUrl()).build(); + + TimeLimiterRegistry timeLimiterRegistry = TimeLimiterRegistry.of( + TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(1)).build()); + CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry.of( + CircuitBreakerConfig.custom().ignoreExceptions(ProductNotFoundException.class).build()); + RetryRegistry retryRegistry = RetryRegistry.of(RetryConfig.custom().maxAttempts(1).build()); + BulkheadRegistry bulkheadRegistry = BulkheadRegistry.of( + BulkheadConfig.custom().maxConcurrentCalls(1).maxWaitDuration(Duration.ZERO).build()); + + adapter = new ApiAffinityIdsAdapter( + webClient, timeLimiterRegistry, circuitBreakerRegistry, retryRegistry, bulkheadRegistry); + } + + @Test + void returnsSimilarIdsInOrder() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1/similarids")) + .willReturn(aResponse().withHeader("Content-Type", "application/json").withBody("[\"2\",\"3\",\"4\"]"))); + + StepVerifier.create(adapter.findAffinityProductIds("1")) + .assertNext(ids -> assertEquals(List.of("2", "3", "4"), ids)) + .verifyComplete(); + } + + @Test + void coalescesConcurrentRequestsForTheSameProduct() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1/similarids")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withFixedDelay(100) + .withBody("[2,3,4]"))); + + StepVerifier.create(Flux.range(0, 50) + .flatMap(ignored -> adapter.findAffinityProductIds("1"), 50) + .collectList()) + .assertNext(results -> { + assertEquals(50, results.size()); + results.forEach(ids -> assertEquals(List.of("2", "3", "4"), ids)); + }) + .verifyComplete(); + + wireMock.verify(1, getRequestedFor(urlEqualTo(PRODUCT_ENDPOINT + "/1/similarids"))); + } + + @Test + void rejectsExcessConcurrentCallsWithoutOverloadingTheDependency() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1/similarids")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withFixedDelay(100).withBody("[\"2\"]"))); + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/2/similarids")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("[\"3\"]"))); + + StepVerifier.create(Flux.mergeDelayError( + 2, adapter.findAffinityProductIds("1"), adapter.findAffinityProductIds("2"))) + .expectNextCount(1) + .expectError(ExternalServiceException.class) + .verify(); + + assertEquals(1, wireMock.getAllServeEvents().size()); + } + + @Test + void mapsNotFoundToProductNotFoundException() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/5/similarids")).willReturn(notFound())); + + StepVerifier.create(adapter.findAffinityProductIds("5")) + .expectError(ProductNotFoundException.class) + .verify(); + } + + @Test + void mapsServerErrorToExternalServiceException() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/6/similarids")).willReturn(serverError())); + + StepVerifier.create(adapter.findAffinityProductIds("6")) + .expectError(ExternalServiceException.class) + .verify(); + } + + @Test + void rejectsEmptyResponseThatViolatesTheExternalContract() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1/similarids")) + .willReturn(aResponse().withHeader("Content-Type", "application/json"))); + + StepVerifier.create(adapter.findAffinityProductIds("1")) + .expectError(ExternalServiceException.class) + .verify(); + } + + @Test + void timesOutOnSlowResponseInsteadOfHanging() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1000/similarids")) + .willReturn(aResponse().withFixedDelay(5000).withBody("[]"))); + + StepVerifier.create(adapter.findAffinityProductIds("1000")) + .expectError(ExternalServiceException.class) + .verify(Duration.ofSeconds(2)); + } + +} diff --git a/src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiProductDetailAdapterTest.java b/src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiProductDetailAdapterTest.java new file mode 100644 index 0000000..6156bb7 --- /dev/null +++ b/src/test/java/com/example/productaffinity/infraestructure/adapter/outcoming/client/ApiProductDetailAdapterTest.java @@ -0,0 +1,158 @@ +package com.example.productaffinity.infraestructure.adapter.outcoming.client; + +import com.example.productaffinity.domain.exception.ProductDetailUnavailableException; +import com.example.productaffinity.domain.model.Product; +import com.example.productaffinity.infraestructure.adapter.outcomming.client.ApiProductDetailAdapter; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import io.github.resilience4j.bulkhead.BulkheadConfig; +import io.github.resilience4j.bulkhead.BulkheadRegistry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.github.resilience4j.timelimiter.TimeLimiterConfig; +import io.github.resilience4j.timelimiter.TimeLimiterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.Duration; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.notFound; +import static com.github.tomakehurst.wiremock.client.WireMock.serverError; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApiProductDetailAdapterTest { + + private final static String PRODUCT_ENDPOINT = "/product"; + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance().build(); + + private ApiProductDetailAdapter adapter; + + @BeforeEach + void setUp() { + WebClient webClient = WebClient.builder().baseUrl(wireMock.baseUrl()).build(); + + TimeLimiterRegistry timeLimiterRegistry = TimeLimiterRegistry.of( + TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build()); + RetryRegistry retryRegistry = RetryRegistry.of(RetryConfig.custom().maxAttempts(1).build()); + BulkheadRegistry bulkheadRegistry = BulkheadRegistry.of( + BulkheadConfig.custom().maxConcurrentCalls(1).maxWaitDuration(Duration.ZERO).build()); + + adapter = new ApiProductDetailAdapter( + webClient, timeLimiterRegistry, retryRegistry, bulkheadRegistry); + } + + @Test + void returnsProductDetail() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("{\"id\":\"1\",\"name\":\"Shirt\",\"price\":9.99,\"availability\":true}"))); + + StepVerifier.create(adapter.findProductDetail("1")) + .assertNext(product -> assertEquals(new Product("1", "Shirt", new BigDecimal("9.99"), true), product)) + .verifyComplete(); + } + + @Test + void coalescesConcurrentRequestsForTheSameProduct() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withFixedDelay(100) + .withBody("{\"id\":\"1\",\"name\":\"Shirt\",\"price\":9.99,\"availability\":true}"))); + + StepVerifier.create(Flux.range(0, 50) + .flatMap(ignored -> adapter.findProductDetail("1"), 50) + .collectList()) + .assertNext(products -> assertEquals(50, products.size())) + .verifyComplete(); + + wireMock.verify(1, getRequestedFor(urlEqualTo("/product/1"))); + } + + @Test + void rejectsExcessConcurrentCallsWithoutOverloadingTheDependency() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withFixedDelay(100) + .withBody("{\"id\":\"1\",\"name\":\"Shirt\",\"price\":9.99,\"availability\":true}"))); + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/2")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("{\"id\":\"2\",\"name\":\"Dress\",\"price\":19.99,\"availability\":true}"))); + + StepVerifier.create(Flux.mergeDelayError( + 2, adapter.findProductDetail("1"), adapter.findProductDetail("2"))) + .expectNextCount(1) + .expectError(ProductDetailUnavailableException.class) + .verify(); + + assertEquals(1, wireMock.getAllServeEvents().size()); + } + + @Test + void mapsNotFoundToUnavailableProductDetail() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/5")).willReturn(notFound())); + + StepVerifier.create(adapter.findProductDetail("5")) + .expectError(ProductDetailUnavailableException.class) + .verify(); + } + + @Test + void mapsServerErrorToUnavailableProductDetail() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/6")).willReturn(serverError())); + + StepVerifier.create(adapter.findProductDetail("6")) + .expectError(ProductDetailUnavailableException.class) + .verify(); + } + + @Test + void mapsTimeoutToUnavailableProductDetail() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/10000")) + .willReturn(aResponse().withFixedDelay(5000) + .withBody("{\"id\":\"10000\",\"name\":\"Coat\",\"price\":1.0,\"availability\":true}"))); + + StepVerifier.create(adapter.findProductDetail("10000")) + .expectError(ProductDetailUnavailableException.class) + .verify(Duration.ofSeconds(2)); + } + + @Test + void rejectsProductDetailThatViolatesTheExternalContract() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("{\"id\":\"1\",\"price\":9.99,\"availability\":true}"))); + + StepVerifier.create(adapter.findProductDetail("1")) + .expectError(ProductDetailUnavailableException.class) + .verify(); + } + + @Test + void repeatedTimeoutsOnOneProductDoNotAffectAHealthyProduct() { + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1000")) + .willReturn(aResponse().withFixedDelay(5000).withBody("{}"))); + wireMock.stubFor(get(urlEqualTo(PRODUCT_ENDPOINT + "/1")) + .willReturn(aResponse().withHeader("Content-Type", "application/json") + .withBody("{\"id\":\"1\",\"name\":\"Shirt\",\"price\":9.99,\"availability\":true}"))); + + for (int i = 0; i < 15; i++) { + StepVerifier.create(adapter.findProductDetail("1000")) + .expectError(ProductDetailUnavailableException.class) + .verify(Duration.ofSeconds(2)); + } + + StepVerifier.create(adapter.findProductDetail("1")) + .assertNext(product -> assertEquals(new Product("1", "Shirt", new BigDecimal("9.99"), true), product)) + .verifyComplete(); + } +}