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