Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/static/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,12 @@ paths:
"*/*":
schema:
"$ref": "#/components/schemas/EntityGraphFlatDtoOut"
'400':
description: Invalid graph query parameter value
content:
"*/*":
schema:
"$ref": "#/components/schemas/ErrorResponse"
'404':
description: Entity not found with the provided identifier
content:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ public class SwaggerDescription {
public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges";
public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape.";
public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved";
public static final String RESPONSE_INVALID_GRAPH_QUERY = "Invalid graph query parameter value";
public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph";
public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph";
public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.decathlon.idp_core.infrastructure.adapters.api.controller;

import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE;
Expand All @@ -11,6 +12,7 @@
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_TRAVERSAL_MODE_DESCRIPTION;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER;
import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_GRAPH_QUERY;
import static org.springframework.http.HttpStatus.OK;

import java.util.List;
Expand Down Expand Up @@ -77,6 +79,7 @@ public class EntityGraphController {
@ResponseStatus(OK)
@Operation(summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, responses = {
@ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))),
@ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_GRAPH_QUERY, content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
Comment thread
hamdirahalpartner-del marked this conversation as resolved.
@ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = @Content(schema = @Schema(implementation = ErrorResponse.class)))})
public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templateIdentifier,
@PathVariable @NotBlank String entityIdentifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.springframework.http.HttpStatus.NOT_FOUND;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
Expand All @@ -20,6 +21,7 @@
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;

import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException;
Expand Down Expand Up @@ -296,6 +298,22 @@ public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
}

/// Handles invalid request parameter types, including unknown enum values.
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException ex) {
String errorMessage = "Invalid value '%s' for parameter '%s'.".formatted(ex.getValue(),
ex.getName());
Class<?> requiredType = ex.getRequiredType();
if (requiredType != null && requiredType.isEnum()) {
errorMessage += " Allowed values: " + Arrays.toString(requiredType.getEnumConstants()) + ".";
} else if (requiredType != null) {
errorMessage += " Expected type: " + requiredType.getSimpleName() + ".";
}
log.warn("Request parameter type mismatch: {}", errorMessage);
return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage);
}

/// Handles JSON parsing and deserialization errors from request bodies.
///
/// **User-friendly parsing:** Converts technical JSON parsing errors into
Expand Down Expand Up @@ -606,7 +624,6 @@ public ResponseEntity<ErrorResponse> handleEntityDynamicMappingNotFoundException
}

/// Handles creation of a dynamic mapping whose identifier already exists.
///
/// HTTP mapping: Maps EntityDynamicMappingAlreadyExistsException to HTTP 409
/// Conflict, surfacing the uniqueness violation with business meaning.
@ExceptionHandler(EntityDynamicMappingAlreadyExistsException.class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import java.lang.reflect.Constructor;
Expand All @@ -17,14 +19,18 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException;
import com.decathlon.idp_core.domain.exception.entity.EntityValidationException;
Expand All @@ -35,6 +41,10 @@
import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException;
import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException;
import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException;
import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphQueryOptions;
import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode;
import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService;
import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityGraphController;
import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse;

/// Comprehensive unit tests for [ApiExceptionHandler].
Expand All @@ -55,6 +65,87 @@ void setUp() throws Exception {
exceptionHandler = constructor.newInstance();
}

@Nested
@DisplayName("Request parameter conversion")
class RequestParameterConversionTests {

private EntityGraphService graphService;
private RestTestClient client;

@BeforeEach
void setUpClient() {
graphService = mock(EntityGraphService.class);
client = RestTestClient.bindToController(new EntityGraphController(graphService))
.configureServer(builder -> builder.setControllerAdvice(exceptionHandler)).build();
}

@ParameterizedTest
@CsvSource({"traversal_mode, OUTBOUND_ONL", "traversal_mode, UNKNOWN",
"traversal_mode, outbound_only", "depth, abc", "include_data, invalid"})
@DisplayName("Should return 400 with a description for invalid graph parameters")
void shouldRejectInvalidGraphParameters(String parameter, String value) {
client.get().uri("/api/v1/entities/product/test/graph?{parameter}={value}", parameter, value)
.exchange().expectStatus().isBadRequest().expectBody(ErrorResponse.class).value(body -> {
assertEquals("BAD_REQUEST", body.getError());
assertTrue(body.getErrorDescription().contains(parameter));
assertTrue(body.getErrorDescription().contains(value));
if ("traversal_mode".equals(parameter)) {
assertTrue(body.getErrorDescription()
.contains("Allowed values: [DIRECT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY]."));
}
});
verifyNoInteractions(graphService);
}

@ParameterizedTest
@EnumSource(EntityGraphTraversalMode.class)
@DisplayName("Should continue accepting all supported traversal modes")
void shouldAcceptSupportedTraversalModes(EntityGraphTraversalMode mode) {
client.get().uri("/api/v1/entities/product/test/graph?traversal_mode={mode}", mode).exchange()
.expectStatus().isOk();

verify(graphService).getEntityGraph("product", "test",
new EntityGraphQueryOptions(1, false, false, Set.of(), Set.of(), mode));
}

@Test
@DisplayName("Should preserve the default traversal mode")
void shouldPreserveDefaultTraversalMode() {
client.get().uri("/api/v1/entities/product/test/graph").exchange().expectStatus().isOk();

verify(graphService).getEntityGraph("product", "test", new EntityGraphQueryOptions(1, false,
false, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE));
}

@Test
@DisplayName("Should describe the expected type for non-enum parameters")
void shouldDescribeExpectedType() {
var exception = new MethodArgumentTypeMismatchException("abc", int.class, "depth",
mock(MethodParameter.class), new IllegalArgumentException());

var response = exceptionHandler.handleMethodArgumentTypeMismatchException(exception);

assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("Invalid value 'abc' for parameter 'depth'. Expected type: int.",
response.getBody().getErrorDescription());
}

@Test
@DisplayName("Should handle parameter type mismatches without a required type")
void shouldHandleUnknownRequiredType() {
var exception = new MethodArgumentTypeMismatchException("invalid", null, "parameter",
mock(MethodParameter.class), new IllegalArgumentException());

var response = exceptionHandler.handleMethodArgumentTypeMismatchException(exception);

assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals("Invalid value 'invalid' for parameter 'parameter'.",
response.getBody().getErrorDescription());
}
}

@Nested
@DisplayName("Domain Exception Handling")
class DomainExceptionTests {
Expand Down
Loading