Tests + fixes
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<resilience4j.version>2.3.0</resilience4j.version>
|
||||
<wiremock.version>3.13.1</wiremock.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -55,6 +56,12 @@
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>${wiremock.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
+23
@@ -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<ResponseEntity<Void>> handleNotFound(ProductNotFoundException ex) {
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.NOT_FOUND).build());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ExternalServiceException.class)
|
||||
public Mono<ResponseEntity<Void>> handleExternalServiceFailure(ExternalServiceException ex) {
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build());
|
||||
}
|
||||
}
|
||||
+35
@@ -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<ResponseEntity<Flux<Product>>> 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());
|
||||
}
|
||||
}
|
||||
+56
-2
@@ -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<Product> findProductDetail(final String productId) {
|
||||
return null;
|
||||
public Mono<Product> findProductDetail(String productId) {
|
||||
return requestCoalescer.execute(productId, () -> fetchProductDetail(productId));
|
||||
}
|
||||
|
||||
public Mono<Product> fetchProductDetail(final String productId) {
|
||||
Mono<Product> 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());
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
+142
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+158
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user