Tests + fixes

This commit is contained in:
Jesus Navalon
2026-07-11 21:46:22 +02:00
parent 9799833a81
commit 570ea90e68
8 changed files with 501 additions and 3 deletions
+7
View File
@@ -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) {
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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();
}
}