From d807c697d72b8e25806481443238f01f242185bf Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Fri, 24 Apr 2026 14:05:10 +0200 Subject: [PATCH 01/27] feat(core): add post entity --- docker-compose.yml | 3 +- docs/src/concepts/entities.md | 264 +++++++++++++---- .../code/domain-infrastructure.md | 7 +- docs/src/static/swagger.yaml | 272 +++++++++++++++++- .../domain/constant/ValidationMessages.java | 35 +++ .../EntityAlreadyExistsException.java | 17 ++ .../exception/EntityNotFoundException.java | 6 +- .../exception/EntityValidationException.java | 30 ++ .../idp_core/domain/model/entity/Entity.java | 6 +- .../domain/port/EntityRepositoryPort.java | 4 +- .../service/{ => entity}/EntityService.java | 66 +++-- .../entity/EntityValidationService.java | 152 ++++++++++ .../domain/service/entity/Violations.java | 35 +++ .../property/PropertyValidationService.java | 116 ++++++++ .../configuration/SecurityConfiguration.java | 1 + .../api/configuration/SwaggerDescription.java | 16 ++ .../api/controller/EntityController.java | 47 ++- .../adapters/api/dto/in/EntityDtoIn.java | 51 +++- .../api/handler/ApiExceptionHandler.java | 44 ++- .../api/mapper/entity/EntityDtoInMapper.java | 53 ++-- .../api/mapper/entity/EntityDtoOutMapper.java | 2 +- .../persistence/PostgresEntityAdapter.java | 14 +- .../repository/JpaEntityRepository.java | 2 + .../service/entity/EntityServiceTest.java | 158 ++++++++++ .../entity/EntityValidationServiceTest.java | 235 +++++++++++++++ .../PropertyValidationServiceTest.java | 80 ++++++ .../api/controller/EntityControllerTest.java | 142 +++++++-- .../EntityTemplateControllerTest.java | 4 - .../api/handler/ApiExceptionHandlerTest.java | 90 ++++-- ...stEntityTemplate_400_properties_empty.json | 6 + ...late_400_withoutPropertiesDefinitions.json | 6 + .../json/entity/v1/postEntity_201.json | 16 +- .../entity/v1/postEntity_201_minimal.json | 4 + .../v1/postEntity_201_with_relations.json | 14 + .../v1/postEntity_400_identifier_missing.json | 7 + .../v1/postEntity_400_name_missing.json | 7 + .../postEntity_400_property_value_blank.json | 8 + .../postEntity_400_relation_name_blank.json | 11 + .../entity/v1/postEntity_409_duplicate.json | 6 + 39 files changed, 1816 insertions(+), 221 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity}/EntityService.java (59%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java create mode 100644 src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json create mode 100644 src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json diff --git a/docker-compose.yml b/docker-compose.yml index be4859d..139089d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ --- -version: "3.8" services: postgres: - image: postgres:14 + image: postgres:18 environment: POSTGRES_USER: idpcore POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 4a78945..92598c0 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -3,17 +3,17 @@ title: Entities description: Understand Entities - instances of Entity Templates with actual data --- -Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity is the house built from that blueprint. +Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity +is the house built from that blueprint. ## Overview An Entity contains: -- **Identity** - Unique identifier and title +- **Identity** - Unique identifier and name - **Template Reference** - Which template it instantiates - **Properties** - Actual values for the template's property definitions - **Relations** - Links to other entities -- **Audit Fields** - Creation/modification timestamps and actors ```mermaid flowchart LR @@ -36,26 +36,26 @@ flowchart LR ### Complete Example -Here's an entity instantiated from the `sonar_project` template: +Here's an entity instantiated from the `web-service` template: ```json { - "identifier": "decathlon_my-backend-project", - "title": "My Backend Project", - "template": "sonar_project", + "identifier": "my-web-service", + "name": "my-web-service", + "template_identifier": "web-service", "properties": { - "project_name": "My Backend Project", - "last_analysis_date": "2025-11-28T12:20:38+0000", - "issues_number": 137, - "loc": 20000 + "port": "8080", + "environment": "dev" }, "relations": { - "github_repository": "my-backend-repo" + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] }, - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt" + "relations_as_target": {} } ``` @@ -63,17 +63,104 @@ Here's an entity instantiated from the `sonar_project` template: ## Core Fields -| Field | Type | Description | -| ------------ | -------- | -------------------------------------------- | -| `identifier` | String | Unique identifier for this entity | -| `title` | String | Human-readable name | -| `template` | String | The Entity Template this entity instantiates | -| `properties` | Object | Key-value pairs of property data | -| `relations` | Object | Links to other entities | -| `created_at` | DateTime | When the entity was created | -| `created_by` | String | Who created the entity | -| `updated_at` | DateTime | Last modification time | -| `updated_by` | String | Who last modified the entity | +| Field | Type | Description | +|-----------------------|----------|----------------------------------------------| +| `identifier` | String | Unique identifier within the template scope | +| `name` | String | Human-readable name | +| `template_identifier` | String | The Entity Template this entity instantiates | +| `properties` | Object | Key-value pairs of property data | +| `relations` | Object | Links to other entities (grouped by name) | + +--- + +## Creating an Entity + +You create an entity by sending a `POST` request to the entities endpoint, specifying the template identifier in the URL +path. + +### Endpoint + +```text +POST /api/v1/entities/{templateIdentifier} +``` + +### Request Body + +```json +{ + "name": "my-web-service", + "identifier": "my-web-service", + "properties": { + "port": "8080", + "environment": "dev" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + } + ] +} +``` + +### Validation + +IDP-Core validates entities at two levels: **syntactic validation** at the API boundary and **semantic validation** +against the template definition. + +#### Syntactic Validation (API Layer) + +The API enforces basic structural rules on the request body before any business logic runs: + +| Field | Rule | Error Message | +|-----------------------------------------|---------------------|--------------------------------------| +| `name` | Required, not blank | Entity name is mandatory | +| `identifier` | Required, not blank | Entity identifier is mandatory | +| `relations[].name` | Required, not blank | Relation name is mandatory | +| `relations[].target_entity_identifiers` | Required, not null | Relation target identifiers required | + +If any rule fails, the API returns `400 Bad Request` with a description of the violation. + +#### Semantic Validation (Domain Layer) + +After syntactic checks pass, the domain service validates the entity against its template definition: + +- **Template existence** - The template identifier must match an existing template. Returns `404 Not Found` if the + template does not exist. +- **Property value types** - Values must conform to the property definition type (STRING, NUMBER, BOOLEAN). +- **Property rules** - Values must satisfy the template's property rules (min/max length, format, regex, enum). +- **Required properties** - All properties marked as required in the template must be present. +- **Duplicate check** - An entity with the same identifier must not already exist for the template. Returns + `409 Conflict` if it does. + +### Response Codes + +| Code | Description | +|-------|----------------------------------------------------------------| +| `201` | Entity created successfully | +| `400` | Invalid request body or validation failure | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template not found for the given identifier | +| `409` | An entity with this identifier already exists for the template | +| `500` | Unexpected server error | + +### Minimal Example + +You can create an entity with only the required fields: + +```json +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} +``` + +Properties and relations are optional in the request body. The domain layer validates that all *required* properties (as +defined in the template) are present. --- @@ -84,17 +171,17 @@ Properties contain the actual data values. The structure follows the template's ```json { "properties": { - "project_name": "My Backend Project", // STRING - "issues_number": 137, // NUMBER - "loc": 20000, // NUMBER - "last_analysis_date": "2025-11-28..." // STRING (date-time) + "project_name": "My Backend Project", + "issues_number": 137, + "loc": 20000, + "last_analysis_date": "2025-11-28..." } } ``` -### Validation +### Validation of properties -System validates values against the template's property rules: +The system validates values against the template's property rules: - Required properties must be present - Types must match: STRING, NUMBER, or BOOLEAN @@ -104,51 +191,119 @@ System validates values against the template's property rules: ## Relations -Relations link entities together, forming a graph. It references the entity identifiers of related entities. +Relations link entities together, forming a graph. Each relation references the entity identifiers of related entities. -### One-to-One Relations (`to_many: false`) +### Creating Relations -For consistency, even single relations are represented as arrays: +When creating an entity, you specify relations as an array of objects, each with a `name` and a list of +`target_entity_identifiers`: ```json { - "relations": { - "owned_by": ["platform-team"] - } + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + }, + { + "name": "owned-by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -### One-to-Many Relations (`to_many: true`) +### Relations in Responses -When multiple related entities are allowed, you can list several identifiers in the relation array: +In API responses, relations are grouped by name and include summary information about each target entity: ```json { "relations": { - "components": ["frontend", "backend", "database"] + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + }, + { + "identifier": "web-api-2", + "name": "Web API 2" + } + ] + }, + "relations_as_target": { + "depends-on": [ + { + "identifier": "frontend-app", + "name": "Frontend App" + } + ] } } ``` ---- +The `relations_as_target` field shows reverse relationships—other entities that reference this entity. -## Audit Fields +### One-to-One Relations (`to_many: false`) -Every entity tracks who created/modified it and when: +For consistency, even single relations are represented as arrays: ```json { - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "auth0|65c1d23377c9bea7d7adc415", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "webhook_integration_sonar" + "relations": [ + { + "name": "owned_by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -The `created_by` and `updated_by` fields contain: +### One-to-Many Relations (`to_many: true`) -- User IDs for manual operations -- Integration IDs for automated data ingestion +When multiple related entities are allowed, list several identifiers: + +```json +{ + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] +} +``` + +--- + +## Retrieving Entities + +### List Entities by Template + +Retrieve a paginated list of entities for a given template: + +```text +GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc +``` + +### Get Entity by Identifier + +Retrieve a specific entity using its template and entity identifiers: + +```text +GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +``` --- @@ -157,7 +312,8 @@ The `created_by` and `updated_by` fields contain: Because templates are configured at runtime, the entity structure is **dynamic**: > [!WARNING] -> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure depends on the template configuration. +> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure +> depends on the template configuration. > > This means: > @@ -168,4 +324,4 @@ Because templates are configured at runtime, the entity structure is **dynamic** - **[Properties](properties.md)** - Property types and validation rules - **[Relations](relations.md)** - How entities connect -- **[Calculated Properties](calculated-properties.md)** - Automatic computations +- **[API Reference](../api/index.md)** - Interactive Swagger UI documentation diff --git a/docs/src/contributing/code/domain-infrastructure.md b/docs/src/contributing/code/domain-infrastructure.md index 8b6a25c..ad1dbf9 100644 --- a/docs/src/contributing/code/domain-infrastructure.md +++ b/docs/src/contributing/code/domain-infrastructure.md @@ -33,7 +33,12 @@ domain/ │ ├── EntityTemplateRepositoryPort │ └── RelationRepositoryPort └── service/ # Domain services logic orchestration - ├── EntityService + ├── entity/ + │ ├── EntityService # Orchestrates entity CRUD with validation + │ ├── EntityValidationService # Entity validation pipeline (template, uniqueness, structure, rules) + │ └── Violations # Mutable accumulator of validation violation messages + ├── property/ + │ └── PropertyValidationService # Validates property values against type and rules (STRING, NUMBER, BOOLEAN) ├── EntityTemplateService └── RelationService ``` diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 37c9d48..accb23c 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,6 +9,8 @@ security: - clientId: [] - bearer: [] tags: + - name: Entities Management + description: Operations related to entity management - name: Entities Templates Management description: Operations related to entity template management paths: @@ -160,6 +162,143 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}: + get: + tags: + - Entities Management + summary: Get entities by template identifier + description: Retrieve a paginated list of entities with optional sorting + operationId: getEntities + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Management + summary: Create a new entity + description: Create a new entity in the system with the provided information + operationId: createEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + required: true + responses: + '201': + description: Entity created successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '409': + description: Entity already exists in this template + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateCreateDtoIn: @@ -173,7 +312,7 @@ components: minLength: 1 name: type: string - description: Unique Entity Template name + description: Entity Template name example: Service maxLength: 255 minLength: 1 @@ -201,10 +340,10 @@ components: properties: name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 + minLength: 0 pattern: "^[a-zA-Z0-9 _-]+$" description: type: string @@ -356,11 +495,6 @@ components: type: object description: Output DTO for property definition properties: - id: - type: string - format: uuid - description: Unique identifier of the property definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Property name @@ -437,11 +571,6 @@ components: type: object description: Output DTO for relation definition properties: - id: - type: string - format: uuid - description: Unique identifier of the relation definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Name of the relation @@ -535,6 +664,86 @@ components: - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityDtoIn: + type: object + description: Input DTO for creating or updating an entity + properties: + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: {} + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name + RelationDtoIn: + type: object + description: Input DTO for an entity relation instance + properties: + name: + type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 + target_entity_identifiers: + type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 + items: + type: string + required: + - name + - target_entity_identifiers + EntityDtoOut: + type: object + properties: + template_identifier: + type: string + name: + type: string + identifier: + type: string + properties: + type: object + additionalProperties: {} + relations: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + relations_as_target: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + EntitySummaryDto: + type: object + properties: + identifier: + type: string + name: + type: string PageableObject: type: object properties: @@ -572,15 +781,46 @@ components: $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 last: type: boolean - totalPages: + size: + type: integer + format: int32 + number: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + EntityPageResponse: + type: object + description: Paginated response containing Entity objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 - first: + totalPages: + type: integer + format: int32 + last: type: boolean size: type: integer @@ -590,6 +830,8 @@ components: format: int32 sort: $ref: '#/components/schemas/SortObject' + first: + type: boolean numberOfElements: type: integer format: int32 diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 8d30dda..369f434 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -20,6 +20,24 @@ public class ValidationMessages { public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = + "Numeric rule '{rule}' is not allowed for STRING properties"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = + "Rule 'min_length' must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = + "Rule 'max_length' must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = + "BOOLEAN properties do not allow validation rules"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -27,4 +45,21 @@ public class ValidationMessages { public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + public static String minMaxConstraintViolated(String ruleName) { + return "Rule 'min_" + ruleName + "' must be lower than or equal to 'max_" + ruleName + "'"; + } + + public static String ruleNotAllowed(String ruleName, String propertyType) { + return "Rule '" + ruleName + "' is not allowed for " + propertyType + " properties"; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java new file mode 100644 index 0000000..bd76169 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; + +import com.decathlon.idp_core.domain.model.entity.Entity; + +/// Domain exception for duplicate [Entity] business entities within a template scope. +public class EntityAlreadyExistsException extends RuntimeException { + + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java index 2942d91..cc7d4a8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.domain.exception; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; + +import com.decathlon.idp_core.domain.model.entity.Entity; + /// Domain exception for missing [Entity] business entities. /// /// **Business purpose:** Represents the business rule violation when attempting @@ -20,7 +24,7 @@ public class EntityNotFoundException extends RuntimeException { /// @param templateIdentifier the identifier of the template /// @param entityIdentifier the identifier of the entity public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format("Entity not found with template identifier %s and entity identifier '%s'", templateIdentifier, entityIdentifier)); + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java new file mode 100644 index 0000000..ca9da64 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; + +import java.util.List; + +import lombok.Getter; + +/// Domain exception for entity schema validation failures +@Getter +public class EntityValidationException extends RuntimeException { + + /** + * -- GETTER -- + * Returns the list of individual validation violation messages. + * /// + * /// + * @return immutable list of violation messages + */ + private final List violations; + + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2ec901e..6250a5a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.model.entity; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY; import java.util.List; @@ -24,9 +26,9 @@ public record Entity( @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, List properties, diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 1c80cb8..0b2c4b8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -30,7 +30,9 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + + Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java similarity index 59% rename from src/main/java/com/decathlon/idp_core/domain/service/EntityService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index b09cff9..3f5de08 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,21 +1,21 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity; import java.util.List; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; - import jakarta.transaction.Transactional; import jakarta.validation.Valid; -import lombok.AllArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -26,39 +26,37 @@ /// **Key responsibilities:** /// - Entity retrieval with template validation /// - Entity creation with business rule enforcement +/// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries -/// - Relationship integrity validation @Service @AllArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; + private final EntityValidationService entityValidationService; /// Retrieves entities filtered by template with existence validation. /// /// **Contract:** Returns paginated entities that conform to the specified template. /// Template existence is validated first to ensure meaningful results. /// - /// @param pageable pagination configuration for large entity sets + /// @param pageable pagination configuration for large entity sets /// @param templateIdentifier business identifier of the entity template /// @return paginated entities matching the template /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) + .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers public List getEntitiesSummariesByIndentifiers(List identifiers) { return entityRepository.findByIdentifierIn(identifiers); } @@ -69,29 +67,35 @@ public List getEntitiesSummariesByIndentifiers(List ident /// Validates template existence first, then entity existence, ensuring referential integrity. /// /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template + /// @param entityIdentifier unique business identifier of the entity within template /// @return the entity matching both identifiers /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } + entityValidationService.checkTemplateExist(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Validates entity structure against template rules and persists - /// the entity. Future enhancement will include comprehensive business rule validation. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Validates template existence, entity identifier uniqueness within + /// the template scope, and entity/property/relation data integrity before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is invalid + @Transactional public Entity createEntity(@Valid Entity entity) { - // Add validations + entityValidationService.checkTemplateExist(entity.templateIdentifier()); + entityValidationService.checkEntityAlreadyExist(entity); + entityValidationService.validateEntity(entity); return entityRepository.save(entity); } + + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java new file mode 100644 index 0000000..f535e7d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -0,0 +1,152 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +/// Domain validator for [Entity] aggregates. +/// +/// Validation pipeline: +/// 1. Existence checks (template found, entity not duplicated). +/// 2. Syntactic checks on the entity itself (name/identifier, nested properties, relations). +/// 3. Template-driven semantic checks (required, type, rules). +@Service +@AllArgsConstructor +public class EntityValidationService { + + private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; + private final PropertyValidationService propertyValidationService; + + /// Check entity template existence to ensure valid template reference before deeper validations. + /// @param entity the entity whose template existence is to be checked + /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist + void checkTemplateExist(final String entity) { + if (!entityTemplateRepository.existsByIdentifier(entity)) { + throw new EntityTemplateNotFoundException("identifier", entity); + } + } + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// @param entity the entity to validate + /// @throws EntityValidationException when one or more validation rules are violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template + /// @throws EntityTemplateNotFoundException if the referenced template does not exist + void validateEntity(Entity entity) { + checkEntityAlreadyExist(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + + Violations violations = new Violations(); + + validateEntityHeader(entity, violations); + validatePropertiesShape(entity.properties(), violations); + validateRelationsShape(entity.relations(), violations); + validateAgainstTemplate(template, entity.properties(), violations); + + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } + } + + private void validateEntityHeader(Entity entity, Violations violations) { + violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); + violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); + } + + private void validatePropertiesShape(List properties, Violations violations) { + if (properties == null) { + return; + } + for (int i = 0; i < properties.size(); i++) { + Property prop = properties.get(i); + if (prop.name() == null || prop.name().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); + } + if (prop.value() == null || prop.value().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); + } + } + } + + private void validateRelationsShape(List relations, Violations violations) { + if (relations == null) { + return; + } + for (int i = 0; i < relations.size(); i++) { + Relation rel = relations.get(i); + if (rel.name() == null || rel.name().isBlank()) { + violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); + } + if (rel.targetEntityIdentifiers() == null) { + violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); + } + } + } + + /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for validation + /// @param properties the list of properties from the entity to validate + /// @param violations the accumulator for validation violation messages + private void validateAgainstTemplate(EntityTemplate template, + List properties, + Violations violations) { + List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() + .filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null || property.value().isBlank(); + + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); + } + continue; + } + + propertyValidationService + .validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + } + + /// Checks for existing entity with same template and identifier to prevent duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists + void checkEntityAlreadyExist(final Entity entity) { + if (entity.identifier() != null + && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java new file mode 100644 index 0000000..92a3dd6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.domain.service.entity; +import java.util.ArrayList; +import java.util.List; + +/// Mutable accumulator of validation violation messages. +/// +/// Centralises message formatting and indexed-prefix handling so domain +/// validators stay focused on the rule they enforce rather than on string +/// concatenation. Not thread-safe; intended for short-lived per-request use. +final class Violations { + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); + } + } + + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java new file mode 100644 index 0000000..983e1e3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -0,0 +1,116 @@ +package com.decathlon.idp_core.domain.service.property; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/** + * Domain service validating entity property values against template definitions. + */ +@Service +public class PropertyValidationService { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /** + * Validates a concrete property value against its property definition. + * + * @param propertyDefinition property definition with expected type and optional rules + * @param rawValue raw property value + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + if (rawValue == null) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); + } + + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + final BigDecimal parsedValue; + try { + parsedValue = new BigDecimal(rawValue); + } catch (RuntimeException exception) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.minValue())) < 0) { + violations.add(PROPERTY_MIN_VALUE_VIOLATION.formatted(propertyName, rules.minValue())); + } + if (rules.maxValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.maxValue())) > 0) { + violations.add(PROPERTY_MAX_VALUE_VIOLATION.formatted(propertyName, rules.maxValue())); + } + + return List.copyOf(violations); + } + + private List validateBooleanPropertyValue(String propertyName, String rawValue) { + if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + return List.of(); + } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 8105a5d..b882f5b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 53aacc8..d645725 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -26,9 +26,12 @@ public class SwaggerDescription { public static final String NO_CONTENT_CODE = "204"; public static final String PARTIAL_CONTENT_CODE = "206"; public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; public static final String NOT_FOUND_CODE = "404"; public static final String CONFLICT_CODE = "409"; public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; /// Entity Template API endpoint constants public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; @@ -78,11 +81,15 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; // --- Schema (class) descriptions --- @@ -95,6 +102,8 @@ public class SwaggerDescription { public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; // --- Field descriptions (shared) --- public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; @@ -104,6 +113,13 @@ public class SwaggerDescription { public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; public static final String FIELD_PROPERTY_NAME = "Property name"; public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 3f94e44..ad37b94 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -1,6 +1,7 @@ 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.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY; @@ -8,20 +9,29 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; 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_INSUFFICIENT_RIGHTS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -35,7 +45,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.service.EntityService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; @@ -43,7 +53,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -51,7 +60,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// @@ -77,14 +88,14 @@ public class EntityController { /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. /// Template validation is handled by the domain service layer. /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control /// @param templateIdentifier template filter for entity scope limitation /// @return paginated entity DTOs optimized for API consumers @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -105,13 +116,13 @@ public Page getEntities( /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context + /// @param entityIdentifier unique business identifier within template context /// @return entity DTO with full property and relationship data @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity( @@ -128,16 +139,22 @@ public EntityDtoOut getEntity( /// and returns HTTP 201 on success, HTTP 400 for validation errors. /// /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships + /// @param entityDtoIn entity creation payload with properties and relationships /// @return created entity DTO with server-generated identifiers @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/{templateIdentifier}") @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@PathVariable String templateIdentifier, @RequestBody EntityDtoIn entityDtoIn) { + public EntityDtoOut createEntity( + @NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 33bd356..0531655 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,34 +1,79 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATIONS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/// Input DTO for creating a new entity within a template scope. +/// +/// **Infrastructure validation:** Performs syntactic validation at the API boundary +/// using Jakarta Validation annotations. Semantic validation (schema conformance +/// against template definitions) is handled by the domain service layer. @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { + + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") private String name; + + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") private String identifier; + + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") private Map properties; + + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) private List relations; + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. @Data + @Builder @NoArgsConstructor @AllArgsConstructor - - @Builder @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) public static class RelationDtoIn { + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") private String name; + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") private List targetEntityIdentifiers; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index a9d6f59..1cfbf69 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -11,11 +11,14 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -23,7 +26,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.servlet.NoHandlerFoundException; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -83,6 +85,40 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException(EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into @@ -134,12 +170,6 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index a5e6b8f..1f6ad3a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -11,8 +12,6 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -28,38 +27,43 @@ /// /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. - @Component @AllArgsConstructor public class EntityDtoInMapper { + + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value - ); - }) - .toList(); + .map((Map.Entry entry) -> { + String value; + if (entry.getValue() != null) { + value = String.valueOf(entry.getValue()); + } else { + value = null; + } + return new Property( + null, + entry.getKey(), + value + ); + }) + .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, // targetTemplateIdentifier not available in DTO - relDto.getTargetEntityIdentifiers() - )) - .toList(); + .map(relDto -> new Relation( + null, + relDto.getName(), + null, + relDto.getTargetEntityIdentifiers() + )) + .toList(); return new Entity( null, @@ -70,5 +74,4 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp relations ); } - } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 2c295a3..0072134 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -20,7 +20,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.domain.service.EntityService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 27ed5ed..0319c66 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -14,8 +15,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; -import lombok.RequiredArgsConstructor; - @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -40,8 +39,15 @@ public Optional findByTemplateIdentifierAndIdentifier(String templateIde } @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - return jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable).map(mapper::toDomain); + public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + + @Override + public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return Optional.of(pageableEntity.map(mapper::toDomain)); } @Override diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 1debeca..fcabfcb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -24,5 +24,7 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java new file mode 100644 index 0000000..a0a2d15 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -0,0 +1,158 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityService Tests") +class EntityServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should throw when template has no entities page") + void shouldThrowWhenTemplatePageNotFound() { + var pageable = Pageable.ofSize(10); + when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + + assertSame(entity, result); + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityValidationService, entityRepository); + inOrder.verify(entityValidationService).checkTemplateExist("web-service"); + inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); + inOrder.verify(entityValidationService).validateEntity(entity); + inOrder.verify(entityRepository).save(entity); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityValidationService).checkEntityAlreadyExist(entity); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); + + org.mockito.Mockito.doThrow(templateNotFound) + .when(entityValidationService) + .checkTemplateExist("missing-template"); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("missing-template"); + verifyNoInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java new file mode 100644 index 0000000..4cdd394 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -0,0 +1,235 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityValidationService Tests") +class EntityValidationServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should pass checkTemplateExist when template exists") + void shouldPassCheckTemplateExistWhenTemplateExists() { + when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); + + assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); + } + + @Test + @DisplayName("Should throw checkTemplateExist when template does not exist") + void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { + when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityValidationService.checkTemplateExist("missing-template")); + } + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Should throw when template is missing during validateEntity") + void shouldThrowWhenTemplateMissingDuringValidateEntity() { + var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); + } + + @Test + @DisplayName("Should aggregate entity, property, relation, required and rule violations") + void shouldAggregateAllViolationsDuringValidateEntity() { + var portDefinition = new PropertyDefinition( + UUID.randomUUID(), + "port", + "Port", + PropertyType.NUMBER, + true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition( + UUID.randomUUID(), + "ownerEmail", + "Owner email", + PropertyType.STRING, + true, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(requiredDefinition, portDefinition), + List.of()); + + var mockedRelation = org.mockito.Mockito.mock(Relation.class); + when(mockedRelation.name()).thenReturn(" "); + when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); + + var entity = entity( + "web-service", + " ", + " ", + List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), + List.of(mockedRelation)); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + + assertEquals(8, exception.getViolations().size()); + assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); + assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); + assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); + assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); + assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); + assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateEntitySuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(versionDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), + null); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(optionalDefinition), + List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verifyNoInteractions(propertyValidationService); + } + + private Entity entity( + String templateIdentifier, + String identifier, + String name, + List properties, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java new file mode 100644 index 0000000..f8416e6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -0,0 +1,80 @@ +package com.decathlon.idp_core.domain.service.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +@DisplayName("PropertyValidationService Tests") +class PropertyValidationServiceTest { + + private final PropertyValidationService service = new PropertyValidationService(); + + @Test + @DisplayName("Should report type mismatch for non numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should report string constraint violations") + void shouldReportStringRuleViolations() { + var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( + null, + PropertyFormat.EMAIL, + List.of("prod", "dev"), + "^[a-z]+$", + 5, + 3, + null, + null)); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should report number bound violations") + void shouldReportNumberBoundViolations() { + var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( + null, + null, + null, + null, + null, + null, + 10, + 5)); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should accept valid boolean value") + void shouldAcceptBooleanValues() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 9675a33..ccee22d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -17,21 +17,19 @@ import com.decathlon.idp_core.AbstractIntegrationTest; - /// Integration tests for the EntityController REST API endpoints. - /// These tests verify the behavior of entity retrieval endpoints, including - /// pagination, authentication, and lookup by template identifier and entity - /// identifier. +/// Integration tests for the EntityController REST API endpoints. +/// These tests verify the behavior of entity retrieval endpoints, including +/// pagination, authentication, and lookup by template identifier and entity +/// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; - private static final String TEMPLATE_IDENTIFIER = "web-service"; private static final String ENTITY_IDENTIFIER = "web-api-2"; private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated /// retrieval). @@ -44,9 +42,9 @@ class GetEntitiesByTemplateIdentifierTests { @WithMockUser void getEntities_paginated_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) + .param("page", "0") + .param("size", "15") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -63,7 +61,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -71,7 +69,7 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } @@ -81,10 +79,10 @@ void getTemplates_paginated_401_without_user_token() throws Exception { void getEntities_paginated_200_custom() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) + .param("page", "1") + .param("size", "5") + .param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(1)) @@ -100,7 +98,7 @@ void getEntities_paginated_200_custom() throws Exception { @WithMockUser void getEntities_invalid_pagination_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -124,7 +122,7 @@ class GetEntitiesByTemplateAndEntityIdentifierTests { @WithMockUser void getEntityByTemplateAndIdentifier_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) @@ -136,7 +134,7 @@ void getEntityByTemplateAndIdentifier_200() throws Exception { @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -145,7 +143,7 @@ void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } } @@ -159,14 +157,108 @@ class PostEntitiesTests { @DisplayName("Should create entity and return 201") void postEntity_201() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) .andExpect(status().isCreated()) .andReturn(); } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") + ))); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index b6c873c..d204659 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -311,10 +311,6 @@ void postTemplate_400_name_invalid_pattern() throws Exception { /// This test verifies that: /// - Validation error message indicates property definitions are /// @throws Exception if the MockMvc request fails - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @DisplayName("Returns 400 when property name is missing") diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 4d2a73b..c8503d7 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -25,10 +25,11 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; - import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -105,6 +106,45 @@ void shouldHandleEntityTemplateAlreadyExistsException() { assertEquals(HttpStatus.CONFLICT.name(), body.getError()); assertEquals(expectedMessage, body.getErrorDescription()); } + + /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); + + // When + ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -226,6 +266,29 @@ private ConstraintViolation createMockConstraintViolation(String message @DisplayName("HTTP Message Exception Handling") class HttpMessageExceptionTests { + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of( + "Required request body is missing: public ResponseEntity", + "Request body is required" + ), + Arguments.of( + "JSON parse error: Unexpected character", + "Invalid JSON format in request body" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'" + ), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body" + ) + ); + } + /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. /// /// **This test verifies that:** @@ -252,29 +315,6 @@ void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { assertEquals("Invalid request body format", body.getErrorDescription()); } - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. /// /// **This test verifies that different types of HttpMessageNotReadableException are properly @@ -290,7 +330,7 @@ static Stream httpMessageNotReadableExceptionTestData() { /// - User-friendly error description is provided /// - Error response structure is consistent /// - /// @param originalMessage the original exception message to be processed + /// @param originalMessage the original exception message to be processed /// @param expectedErrorDescription the expected user-friendly error description @ParameterizedTest @MethodSource("httpMessageNotReadableExceptionTestData") diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json new file mode 100644 index 0000000..4c00a50 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json @@ -0,0 +1,6 @@ +{ + "identifier": "temp-test-0", + "description": "This is a test template", + "properties_definitions": [], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json new file mode 100644 index 0000000..996e560 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json @@ -0,0 +1,6 @@ +{ + "identifier": "web-service", + "name": "web-service", + "description": "This is a test template", + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json index 82367a2..3593858 100644 --- a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json @@ -1,9 +1,15 @@ { - "name": "microservice-2", - "identifier": "microservice-2", + "name": "web-service-valid-1", + "identifier": "web-service-valid-1", "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", "port": "8080", - "environment": "dev" - }, - "relations": [] + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } } diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json new file mode 100644 index 0000000..678a6bf --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json @@ -0,0 +1,4 @@ +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json new file mode 100644 index 0000000..8643862 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json @@ -0,0 +1,14 @@ +{ + "name": "microservice-with-relations", + "identifier": "microservice-with-relations", + "properties": { + "port": "9090", + "environment": "staging" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": ["web-api-1"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json new file mode 100644 index 0000000..20e6fe2 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json @@ -0,0 +1,7 @@ +{ + "name": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json new file mode 100644 index 0000000..7c0b057 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json @@ -0,0 +1,7 @@ +{ + "identifier": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json new file mode 100644 index 0000000..570184e --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json @@ -0,0 +1,8 @@ +{ + "name": "entity-prop-no-value", + "identifier": "entity-prop-no-value", + "properties": { + "applicationName": "" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json new file mode 100644 index 0000000..9bb5cbc --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json @@ -0,0 +1,11 @@ +{ + "name": "entity-rel-no-name", + "identifier": "entity-rel-no-name", + "properties": {}, + "relations": [ + { + "name": "", + "target_entity_identifiers": ["some-target"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json new file mode 100644 index 0000000..e850f2b --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json @@ -0,0 +1,6 @@ +{ + "name": "Web API 1 duplicate", + "identifier": "web-api-1", + "properties": {}, + "relations": [] +} From e26e26c56affc05d2b5172dd594fbe2621fa1699 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:23:01 +0200 Subject: [PATCH 02/27] feat(core): fix sonar issue and copilot review --- .../service/property/PropertyValidationService.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 983e1e3..e769e0d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -12,6 +12,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -30,6 +32,10 @@ public class PropertyValidationService { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + /** * Validates a concrete property value against its property definition. * @@ -62,7 +68,8 @@ private List validateStringPropertyValue(String propertyName, String raw if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } - if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + if (rules.regex() != null + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() @@ -80,7 +87,7 @@ private List validateNumberPropertyValue(String propertyName, String raw final BigDecimal parsedValue; try { parsedValue = new BigDecimal(rawValue); - } catch (RuntimeException exception) { + } catch (NumberFormatException _) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); } From fd1b3542c653c45bc7d6a5e52ba707e44ac3b951 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:47:05 +0200 Subject: [PATCH 03/27] feat(core): fix sonar review --- .../adapters/api/configuration/SecurityConfiguration.java | 1 - .../infrastructure/adapters/api/controller/EntityController.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index b882f5b..8105a5d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,7 +5,6 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index ad37b94..c221534 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -62,7 +62,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// From a85955429a888822eea7e8d8409990c573d72aa6 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 11:44:56 +0200 Subject: [PATCH 04/27] feat(core): fix sonar qube and test --- .../domain/constant/ValidationMessages.java | 17 - .../model/entity/EntityJpaEntity.java | 4 +- ..._entity_identifier_unique_to_composite.sql | 9 + .../PropertyValidationServiceTest.java | 354 +++++++++++++++--- .../api/handler/ApiExceptionHandlerTest.java | 67 ++++ 5 files changed, 388 insertions(+), 63 deletions(-) create mode 100644 src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 369f434..a5c0d0f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -29,15 +29,6 @@ public class ValidationMessages { public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = - "Numeric rule '{rule}' is not allowed for STRING properties"; - public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = - "Rule 'min_length' must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = - "Rule 'max_length' must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = - "BOOLEAN properties do not allow validation rules"; - public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -54,12 +45,4 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - - public static String minMaxConstraintViolated(String ruleName) { - return "Rule 'min_" + ruleName + "' must be lower than or equal to 'max_" + ruleName + "'"; - } - - public static String ruleNotAllowed(String ruleName, String propertyType) { - return "Rule '" + ruleName + "' is not allowed for " + propertyType + " properties"; - } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 75c3337..9e4e0e2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -21,7 +21,9 @@ @jakarta.persistence.Entity @Data -@Table(name = "entity") +@Table(name = "entity", uniqueConstraints = { + @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) +}) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql new file mode 100644 index 0000000..11255aa --- /dev/null +++ b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql @@ -0,0 +1,9 @@ +-- Change unique constraint on entity table: +-- Drop the unique constraint on identifier alone +-- Add a composite unique constraint on (identifier, template_identifier) +-- This allows the same identifier to exist across different templates + +ALTER TABLE entity DROP CONSTRAINT entity_identifier_key; + +ALTER TABLE entity ADD CONSTRAINT entity_identifier_template_identifier_key + UNIQUE (identifier, template_identifier); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index f8416e6..3f35fc0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -5,6 +5,7 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import com.decathlon.idp_core.domain.constant.ValidationMessages; @@ -18,60 +19,323 @@ class PropertyValidationServiceTest { private final PropertyValidationService service = new PropertyValidationService(); - @Test - @DisplayName("Should report type mismatch for non numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - var violations = service.validatePropertyValue(definition, "not-a-number"); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "hello"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "dev"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "ab"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "too-long-value"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "abc"); + + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); + } + + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12345"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "UNKNOWN"); + + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); + } + + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "active"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "anything"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-an-email"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); + } + + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "user@example.com"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-a-url"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); + } + + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - @Test - @DisplayName("Should report string constraint violations") - void shouldReportStringRuleViolations() { - var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( - null, - PropertyFormat.EMAIL, - List.of("prod", "dev"), - "^[a-z]+$", - 5, - 3, - null, - null)); - - var violations = service.validatePropertyValue(definition, "AA"); - - assertEquals(4, violations.size()); + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); + + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } } - @Test - @DisplayName("Should report number bound violations") - void shouldReportNumberBoundViolations() { - var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( - null, - null, - null, - null, - null, - null, - 10, - 5)); - - var violations = service.validatePropertyValue(definition, "3"); - - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "42"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "50"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "15"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); + } + + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "7"); + + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } + + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "99.5"); + + assertEquals(List.of(), violations); + } } - @Test - @DisplayName("Should accept valid boolean value") - void shouldAcceptBooleanValues() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { + + @Test + @DisplayName("Should accept 'true' value") + void shouldAcceptTrueValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept 'false' value") + void shouldAcceptFalseValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "false"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'TRUE'") + void shouldAcceptUppercaseTrue() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "TRUE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'FALSE'") + void shouldAcceptUppercaseFalse() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "FALSE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } } private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index c8503d7..3433491 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -26,7 +26,9 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; @@ -145,6 +147,55 @@ void shouldHandleEntityValidationException() { assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); assertEquals(exception.getMessage(), body.getErrorDescription()); } + + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); + + // When + ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -282,9 +333,25 @@ static Stream httpMessageNotReadableExceptionTestData() { "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", "Invalid value 'INVALID_TYPE' for property 'type'" ), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'" + ), Arguments.of( "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", "Invalid enum value in request body" + ), + Arguments.of( + "Cannot deserialize value of type `com.example.SomeType`: some other error", + "Cannot deserialize request body property" + ), + Arguments.of( + "Something completely unexpected happened", + "Invalid request body format" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'" ) ); } From 39580890b1154549877e53310f614f0b82528dbd Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 14:56:43 +0200 Subject: [PATCH 05/27] feat(core): fix validation type check --- .../domain/model/entity/Property.java | 11 +- .../entity/EntityValidationService.java | 2 +- .../property/PropertyValidationService.java | 41 +++++- .../api/mapper/entity/EntityDtoInMapper.java | 3 +- .../mapper/EntityPersistenceMapper.java | 2 + .../entity/EntityValidationServiceTest.java | 8 +- .../PropertyValidationServiceTest.java | 122 ++++++++++-------- 7 files changed, 126 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 4c15dcd..015d0fb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -22,6 +22,9 @@ /// - Property values must satisfy all validation rules from [PropertyRules] /// - Required properties cannot have empty values /// - Property types must align with the template's [PropertyType] definition +/// +/// @param rawValue the original untyped value from the API input, used for type checking +/// during validation. May be null when loaded from persistence. public record Property( UUID id, @@ -29,6 +32,12 @@ public record Property( String name, @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value + String value, + + Object rawValue ) { + /// Convenience constructor for persistence and test scenarios where raw value is not needed. + public Property(UUID id, String name, String value) { + this(id, name, value, null); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index f535e7d..7b7c18c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -133,7 +133,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value()) + .validatePropertyValue(definition, property.value(), property.rawValue()) .forEach(violations::add); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index e769e0d..d2ba01c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,12 +38,20 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. + * Type compatibility is checked first against the original raw value + * before applying any rule-based validations. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value + * @param rawValue raw property value as string + * @param originalValue the original untyped value from the API input for type checking, + * may be null when loaded from persistence * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { + List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); + if (!typeMismatch.isEmpty()) { + return typeMismatch; + } return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -51,6 +59,35 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } + /// Checks that the original JSON value type is compatible with the expected [PropertyType]. + /// + /// When `originalValue` is non-null, its Java type is inspected: + /// - STRING expects a Java `String` + /// - NUMBER expects a Java `Number` + /// - BOOLEAN expects a Java `Boolean` + /// + /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped + /// and type validation falls through to the string-based validators. + /// + /// @param propertyName property name for error reporting + /// @param expectedType the expected property type from the template definition + /// @param originalValue the original untyped value from the API input + /// @return a single-element list with a type mismatch message, or an empty list if compatible + private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { + if (originalValue == null) { + return List.of(); + } + boolean compatible = switch (expectedType) { + case STRING -> originalValue instanceof String; + case NUMBER -> originalValue instanceof Number || originalValue instanceof String; + case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; + }; + if (!compatible) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); + } + return List.of(); + } + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { if (rawValue == null) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 1f6ad3a..7bc0a59 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -50,7 +50,8 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp return new Property( null, entry.getKey(), - value + value, + entry.getValue() ); }) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 120fd65..c22ffbb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -17,6 +18,7 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); + @Mapping(target = "rawValue", ignore = true) Property toDomain(PropertyJpaEntity jpa); PropertyJpaEntity toJpa(Property domain); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 4cdd394..02c7116 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -142,7 +142,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); @@ -157,7 +157,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); } @Test @@ -189,10 +189,10 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); } @Test diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 3f35fc0..2ffdef8 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; @@ -28,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null); + var violations = service.validatePropertyValue(definition, null, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -38,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, "hello", "hello"); assertEquals(List.of(), violations); } @@ -49,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "dev", "dev"); assertEquals(List.of(), violations); } @@ -60,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "ab", "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -71,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -82,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "abc", "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -93,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "12345", "12345"); assertEquals(List.of(), violations); } @@ -104,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -115,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "active", "active"); assertEquals(List.of(), violations); } @@ -126,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "anything", "anything"); assertEquals(List.of(), violations); } @@ -137,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -148,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); assertEquals(List.of(), violations); } @@ -159,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -170,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -181,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "AA", "AA"); assertEquals(4, violations.size()); } @@ -193,12 +195,33 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + var violations1 = service.validatePropertyValue(definition, "abc", "abc"); + var violations2 = service.validatePropertyValue(definition, "def", "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } + + @Test + @DisplayName("Should report type mismatch when a number is sent for a STRING property") + void shouldReportTypeMismatchWhenNumberSentForString() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("label", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12", 12); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") + void shouldReportTypeMismatchWhenBooleanSentForString() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "true", true); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } } @Nested @@ -210,7 +233,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -220,7 +243,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(), violations); } @@ -231,7 +254,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50", 50); assertEquals(List.of(), violations); } @@ -242,7 +265,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3", 3); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -253,7 +276,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15", 15); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -265,7 +288,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7", 7); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -277,62 +300,53 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5", 99.5); assertEquals(List.of(), violations); } - } - - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { @Test - @DisplayName("Should accept 'true' value") - void shouldAcceptTrueValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true", true); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } + } - @Test - @DisplayName("Should accept 'false' value") - void shouldAcceptFalseValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); - - var violations = service.validatePropertyValue(definition, "false"); - - assertEquals(List.of(), violations); - } + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - @Test - @DisplayName("Should accept case-insensitive 'TRUE'") - void shouldAcceptUppercaseTrue() { + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, "TRUE"); + var violations = service.validatePropertyValue(definition, value, originalValue); assertEquals(List.of(), violations); } @Test - @DisplayName("Should accept case-insensitive 'FALSE'") - void shouldAcceptUppercaseFalse() { + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "FALSE"); + var violations = service.validatePropertyValue(definition, "yes", "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @Test - @DisplayName("Should report type mismatch for invalid boolean value") - void shouldReportTypeMismatchForInvalidBoolean() { + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } From fb272c259f8a930f45b1072cda4cf5c35d035e46 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:13:06 +0200 Subject: [PATCH 06/27] feat(core): fix review --- .../domain/constant/ValidationMessages.java | 30 ++ .../EntityAlreadyExistsException.java | 13 +- .../{ => entity}/EntityNotFoundException.java | 2 +- .../EntityValidationException.java | 15 +- .../EntityTemplateAlreadyExistsException.java | 3 +- ...ityTemplateNameAlreadyExistsException.java | 3 +- .../EntityTemplateNotFoundException.java | 2 +- ...pertyDefinitionRulesConflictException.java | 25 ++ .../idp_core/domain/model/entity/Entity.java | 3 + .../domain/model/entity/Property.java | 19 +- .../domain/service/EntityTemplateService.java | 6 +- .../domain/service/entity/EntityService.java | 36 ++- .../entity/EntityValidationService.java | 87 +---- .../EntityTemplateValidationService.java | 118 +++++++ .../PropertyDefinitionValidationService.java | 297 ++++++++++++++++++ .../property/PropertyValidationService.java | 84 ++--- .../api/controller/EntityController.java | 6 +- .../api/handler/ApiExceptionHandler.java | 12 +- .../api/mapper/entity/EntityDtoInMapper.java | 23 +- .../api/mapper/entity/EntityDtoOutMapper.java | 81 +++-- .../mapper/EntityPersistenceMapper.java | 19 +- .../service/entity/EntityServiceTest.java | 50 +-- .../entity/EntityValidationServiceTest.java | 124 +++----- .../PropertyValidationServiceTest.java | 78 ++--- .../api/handler/ApiExceptionHandlerTest.java | 12 +- 25 files changed, 773 insertions(+), 375 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityAlreadyExistsException.java (51%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityNotFoundException.java (95%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityValidationException.java (50%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateAlreadyExistsException.java (92%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNameAlreadyExistsException.java (87%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNotFoundException.java (97%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index a5c0d0f..9bf3e8a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -30,6 +30,14 @@ public class ValidationMessages { public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target entity identifier is mandatory and cannot be blank"; @@ -45,4 +53,26 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE + .replace("{rule1}", rule1) + .replace("{rule2}", rule2); + } + + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE + .replace("{rule}", rule) + .replace("{type}", propertyType); + } + + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED + .replace("{constraint}", constraint); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java similarity index 51% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index bd76169..8243748 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -1,10 +1,19 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity.Entity; -/// Domain exception for duplicate [Entity] business entities within a template scope. +/// Domain exception for duplicate [Entity] business entities within the same template context. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an Entity that already exist within a specific template context. +/// This enforces the business invariant that entities must be unique within their template context. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require unique entities within a template context +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { /// Constructs a new exception with template and entity identifiers. diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java similarity index 95% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index cc7d4a8..42c60f6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java similarity index 50% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index ca9da64..42756f0 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; @@ -7,6 +7,19 @@ import lombok.Getter; /// Domain exception for entity schema validation failures +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an entity, or update an entity, with property values that +/// do not conform to the validation rules defined in the entity's template. +/// This includes violations of required properties, type mismatches, and template rules +/// This enforces the business invariant that entities must conform to the validation +/// rules defined in their template's property definitions and relation constraints. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require valid property values +/// that conform to template rules +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity @Getter public class EntityValidationException extends RuntimeException { diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java similarity index 92% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index aff0ad4..12aee0d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -1,9 +1,10 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// Exception thrown when attempting to create an [EntityTemplate] with an identifier that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java similarity index 87% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index 885bc83..d1c7104 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -1,9 +1,10 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// Exception thrown when attempting to create or update an [EntityTemplate] with a name that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java similarity index 97% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java index bca9ccd..c765a4f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import java.util.UUID; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java new file mode 100644 index 0000000..f68a840 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.property; + +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain exception for property rule validation violations. +/// +/// **Business purpose:** Represents the business rule violation when property rules +/// conflict with their assigned property type. This ensures data integrity +/// by preventing invalid rule configurations before persistence. +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Property template updates introducing rule conflicts +public class PropertyDefinitionRulesConflictException extends RuntimeException { + + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + + ": " + violationMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 6250a5a..2b77241 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.UUID; +import org.springframework.validation.annotation.Validated; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; @@ -21,6 +23,7 @@ /// /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. + public record Entity( UUID id, diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 015d0fb..7850124 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -1,7 +1,6 @@ package com.decathlon.idp_core.domain.model.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; import java.util.UUID; @@ -20,24 +19,16 @@ /// **Business invariants:** /// - Property names must match a [PropertyDefinition] name in the entity's template /// - Property values must satisfy all validation rules from [PropertyRules] -/// - Required properties cannot have empty values -/// - Property types must align with the template's [PropertyType] definition -/// -/// @param rawValue the original untyped value from the API input, used for type checking -/// during validation. May be null when loaded from persistence. +/// - Required properties cannot have null/blank values +/// - Property values must be typed according to the template's [PropertyType] definition +/// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved +/// for strict type-mismatch detection at validation time). public record Property( UUID id, @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value, - - Object rawValue + Object value ) { - /// Convenience constructor for persistence and test scenarios where raw value is not needed. - public Property(UUID id, String name, String value) { - this(id, name, value, null); - } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java index d9cf376..7d27fc5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java @@ -12,9 +12,9 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3f5de08..4fa2da2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,18 +2,22 @@ import java.util.List; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -29,10 +33,13 @@ /// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries @Service -@AllArgsConstructor +@Validated +@RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; /// Retrieves entities filtered by template with existence validation. /// @@ -72,8 +79,8 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityTemplateNotFoundException when template doesn't exist /// @throws EntityNotFoundException when entity doesn't exist @Transactional - public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - entityValidationService.checkTemplateExist(templateIdentifier); + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); @@ -81,8 +88,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// Creates and persists a new entity with business validation. /// - /// **Contract:** Validates template existence, entity identifier uniqueness within - /// the template scope, and entity/property/relation data integrity before persisting. + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. /// /// @param entity validated entity to create and persist /// @return the persisted entity with generated identifiers @@ -91,9 +100,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - entityValidationService.checkTemplateExist(entity.templateIdentifier()); - entityValidationService.checkEntityAlreadyExist(entity); - entityValidationService.validateEntity(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + entityValidationService.checkUniqueness(entity); + entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 7b7c18c..4902016 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import java.util.List; import java.util.Map; @@ -16,16 +10,13 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; -import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; /// Domain validator for [Entity] aggregates. @@ -39,34 +30,21 @@ public class EntityValidationService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final PropertyValidationService propertyValidationService; - /// Check entity template existence to ensure valid template reference before deeper validations. - /// @param entity the entity whose template existence is to be checked - /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist - void checkTemplateExist(final String entity) { - if (!entityTemplateRepository.existsByIdentifier(entity)) { - throw new EntityTemplateNotFoundException("identifier", entity); - } - } - - /// Validates intrinsic entity data integrity and template-driven rules. + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. /// /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - /// @throws EntityTemplateNotFoundException if the referenced template does not exist - void validateEntity(Entity entity) { - checkEntityAlreadyExist(entity); - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - + void validateEntity(Entity entity, EntityTemplate template) { Violations violations = new Violations(); - - validateEntityHeader(entity, violations); - validatePropertiesShape(entity.properties(), violations); - validateRelationsShape(entity.relations(), violations); validateAgainstTemplate(template, entity.properties(), violations); if (!violations.isEmpty()) { @@ -74,45 +52,10 @@ void validateEntity(Entity entity) { } } - private void validateEntityHeader(Entity entity, Violations violations) { - violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); - violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); - } - - private void validatePropertiesShape(List properties, Violations violations) { - if (properties == null) { - return; - } - for (int i = 0; i < properties.size(); i++) { - Property prop = properties.get(i); - if (prop.name() == null || prop.name().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); - } - if (prop.value() == null || prop.value().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); - } - } - } - - private void validateRelationsShape(List relations, Violations violations) { - if (relations == null) { - return; - } - for (int i = 0; i < relations.size(); i++) { - Relation rel = relations.get(i); - if (rel.name() == null || rel.name().isBlank()) { - violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); - } - if (rel.targetEntityIdentifiers() == null) { - violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); - } - } - } - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation violation messages + /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, List properties, Violations violations) { @@ -123,7 +66,9 @@ private void validateAgainstTemplate(EntityTemplate template, for (PropertyDefinition definition : definitions) { Property property = propertiesByName.get(definition.name()); - boolean missing = property == null || property.value() == null || property.value().isBlank(); + boolean missing = property == null + || property.value() == null + || (property.value() instanceof String s && s.isBlank()); if (missing) { if (definition.required()) { @@ -133,7 +78,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value(), property.rawValue()) + .validatePropertyValue(definition, property.value()) .forEach(violations::add); } } @@ -141,7 +86,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkEntityAlreadyExist(final Entity entity) { + void checkUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java new file mode 100644 index 0000000..34aab2f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -0,0 +1,118 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import java.util.Objects; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service to centralize all functional validation rules for [EntityTemplate] operations. +/// +/// **Key responsibilities:** +/// - Identifier and name uniqueness enforcement for create and update operations +/// - Property-rule compatibility validation (type vs. rule constraints) delegated to [PropertyDefinitionValidationService] +/// - Template existence verification before deletion +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyDefinitionValidationService propertyDefinitionValidationService; + + /// Validates all business rules before creating a new entity template. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Property rules must be compatible with their declared property type. + /// + /// @param entityTemplate the template candidate to validate + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateForCreate(EntityTemplate entityTemplate) { + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(entityTemplate.name()); + validatePropertyRules(entityTemplate); + } + + /// Validates all business rules before persisting an updated entity template. + /// + /// **Business rules enforced:** + /// - If the identifier changed, the new value must not collide with another template. + /// - If the name changed, the new value must not collide with another template. + /// - Property rules in the merged template must be compatible with their declared type. + /// + /// @param currentIdentifier the identifier of the template being replaced + /// @param existingName the current name of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken + public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate mergedTemplate) { + if (!currentIdentifier.equals(mergedTemplate.identifier())) { + validateIdentifierUniqueness(mergedTemplate.identifier()); + } + if (!Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(mergedTemplate.name()); + } + validatePropertyRules(mergedTemplate); + } + + /// Validates that a template identifier is non-null and refers to an existing template. + /// + /// @param identifier the identifier of the template to delete + /// @throws IllegalArgumentException when `identifier` is null + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void validateForDelete(String identifier) { + if (identifier == null) { + throw new IllegalArgumentException("Template identifier must not be null"); + } + checkTemplateExists(identifier); + } + + /// Checks that the entity template exists. + /// + /// @param identifier the identifier to check for existence + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void checkTemplateExists(String identifier) { + if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException("identifier", identifier); + } + } + + /// Checks that no other template already uses the given identifier. + /// + /// @param identifier the identifier to check for uniqueness + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + public void validateIdentifierUniqueness(String identifier) { + if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + /// Checks that no other template already uses the given name. + /// + /// @param name the name to check for uniqueness + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); + } + } + + public void validatePropertyRules(EntityTemplate entityTemplate) { + if (entityTemplate.propertiesDefinitions() == null) { + return; + } + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java new file mode 100644 index 0000000..cebf279 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -0,0 +1,297 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; + + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.property.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain service for validating property rule compatibility with property types. +/// +/// **Business rules:** +/// - STRING: Allows format, enum_values, regex, max_length, min_length. Rejects numeric rules. +/// - NUMBER: Allows max_value, min_value. Rejects string and format rules. +/// - BOOLEAN: Rejects all rules; rules field must be null or empty. +/// +@Service +public class PropertyDefinitionValidationService { + + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; + + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } + + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); + + switch (type) { + case STRING: + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER: + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN: + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default: + throw new IllegalArgumentException("Unknown property type: " + type); + } + } + + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); + + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + validateRegexPattern(propertyName, rules.regex()); + } + } + + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE + ); + } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE + ); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated(LENGTH) + ); + } + } + + /// Validates rule compatibility and mutual exclusivity for STRING property rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules){ + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) + ); + } + + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES) + ); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX) + ); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES) + ); + } + + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) + ); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) + ); + } + + } + + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) + ); + } + + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) + ); + } + + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) + ); + } + + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + minMaxConstraintViolated(VALUE) + ); + } + } + + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || + rules.enumValues() != null || + rules.regex() != null || + rules.maxLength() != null || + rules.minLength() != null || + rules.maxValue() != null || + rules.minValue() != null) { + + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED + ); + } + } + + /// Validates that the provided regex pattern is syntactically valid. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyDefinitionRulesConflictException if the pattern is syntactically invalid + private void validateRegexPattern(String propertyName, String regexPattern) { + try { + Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + "Invalid regex pattern: " + e.getMessage() + ); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index d2ba01c..604891c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,20 +38,16 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. - * Type compatibility is checked first against the original raw value - * before applying any rule-based validations. + * The value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, + * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is + * normalized to a string and the type-specific rules are evaluated. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value as string - * @param originalValue the original untyped value from the API input for type checking, - * may be null when loaded from persistence + * @param rawValue raw property value preserving its original JSON type * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { - List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); - if (!typeMismatch.isEmpty()) { - return typeMismatch; - } + public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -59,37 +55,9 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } - /// Checks that the original JSON value type is compatible with the expected [PropertyType]. - /// - /// When `originalValue` is non-null, its Java type is inspected: - /// - STRING expects a Java `String` - /// - NUMBER expects a Java `Number` - /// - BOOLEAN expects a Java `Boolean` - /// - /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped - /// and type validation falls through to the string-based validators. - /// - /// @param propertyName property name for error reporting - /// @param expectedType the expected property type from the template definition - /// @param originalValue the original untyped value from the API input - /// @return a single-element list with a type mismatch message, or an empty list if compatible - private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { - if (originalValue == null) { - return List.of(); - } - boolean compatible = switch (expectedType) { - case STRING -> originalValue instanceof String; - case NUMBER -> originalValue instanceof Number || originalValue instanceof String; - case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; - }; - if (!compatible) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); - } - return List.of(); - } - private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { - if (rawValue == null) { + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); } @@ -99,33 +67,41 @@ private List validateStringPropertyValue(String propertyName, String raw var violations = new ArrayList(); - if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); } - if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); } - if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } return List.copyOf(violations); } - private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; - try { - parsedValue = new BigDecimal(rawValue); - } catch (NumberFormatException _) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + switch (rawValue) { + case Number number -> parsedValue = new BigDecimal(number.toString()); + case String string -> { + try { + parsedValue = new BigDecimal(string); + } catch (NumberFormatException _) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + } + case null, default -> { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } } if (rules == null) { @@ -144,8 +120,12 @@ private List validateNumberPropertyValue(String propertyName, String raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, String rawValue) { - if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); + } + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { return List.of(); } return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index c221534..f9f8d90 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -31,7 +31,7 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -74,7 +74,7 @@ @RestController @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") -@AllArgsConstructor +@RequiredArgsConstructor public class EntityController { private final EntityService entityService; @@ -127,7 +127,7 @@ public Page getEntities( public EntityDtoOut getEntity( @PathVariable String templateIdentifier, @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAnIdentifier(templateIdentifier, entityIdentifier); + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); return entityDtoOutMapper.fromEntity(entity); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 1cfbf69..393e68c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -13,12 +13,12 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 7bc0a59..45dc45a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -28,7 +28,7 @@ /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoInMapper { /// Converts an entity creation request DTO to a domain entity. @@ -40,20 +40,11 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value, - entry.getValue() - ); - }) + .map((Map.Entry entry) -> new Property( + null, + entry.getKey(), + entry.getValue() + )) .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 0072134..1216224 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; @@ -20,14 +21,12 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting domain [Entity] objects to API DTOs. /// /// **Infrastructure mapping responsibilities:** @@ -46,7 +45,7 @@ /// - Integrates with Jackson for JSON serialization patterns /// - Stateless design ensures thread safety in web containers @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoOutMapper { private final EntityTemplateService entityTemplateService; @@ -72,11 +71,11 @@ public EntityDtoOut fromEntity(Entity entity) { /// to minimize database queries. Builds summary maps for efficient relationship /// resolution across the entire page. /// - /// @param entities paginated domain entities from repository layer + /// @param entities paginated domain entities from repository layer /// @param entityTemplateIdentifier template identifier for batch template resolution /// @return paginated API DTOs with complete relationship data public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + String entityTemplateIdentifier) { Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( @@ -94,7 +93,7 @@ public Page fromEntitiesPageToDtoPage(Page entities, /// @param entity the entity to map /// @param entityTemplate the template for property type mapping /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); @@ -120,13 +119,13 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return the mapped DTO private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { Map props = mapPropertiesDto(entity, entityTemplate); Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); @@ -163,44 +162,42 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit Property::name, prop -> { PropertyDefinition def = propertiesDefinitions.get(prop.name()); - if (def != null) { - PropertyType type = def.type(); - String value = prop.value(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return null; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); + Object rawValue = prop.value(); + if (def == null || rawValue == null) { + return rawValue; + } + String stringValue = String.valueOf(rawValue); + PropertyType type = def.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(stringValue); + } catch (NumberFormatException _) { + return null; } - // Default to string - return value; - } else { - // Fallback if propertyDefinition is missing - return prop.value(); + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(stringValue); } + return stringValue; })); } /// Maps the relations of an entity to a map of relation names to lists of target /// entity summaries. /// - /// @param entity the entity whose relations to map + /// @param entity the entity whose relations to map /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @return a map of relation names to lists of target entity summaries private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { + Map relatedEntitiesSummaries) { return entity.relations() == null ? Collections.emptyMap() : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .collect(Collectors.groupingBy( + Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() .map(relatedEntitiesSummaries::get) .filter(Objects::nonNull), - Collectors.toList()))); + Collectors.toList()))); } /// @@ -208,11 +205,11 @@ private Map> mapRelationsDto(Entity entity, /// lists of source entity summaries. /// /// @param entity the entity whose relations-as-target to - /// map + /// map /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return a map of relation names to lists of source entity summaries private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { + Map> relationTargetOwnershipsMap) { List relationAsTargetSummaries = relationTargetOwnershipsMap .get(entity.identifier()); if (relationAsTargetSummaries == null) { @@ -271,8 +268,8 @@ private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { return entity.relations() == null ? Collections.emptyList() : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + .flatMap(rel -> rel.targetEntityIdentifiers().stream()) + .collect(Collectors.toSet())); } /// @@ -286,7 +283,7 @@ private List getUniqueTargetIdentifiersInPage(Page entities) { .flatMap(entity -> entity.relations() == null ? Stream.empty() : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .flatMap(rel -> rel.targetEntityIdentifiers().stream())) .collect(Collectors.toSet())); } @@ -309,10 +306,10 @@ private Map buildEntitiesSummariesMap(List tar return targetIdentifiers.isEmpty() ? Collections.emptyMap() : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + .stream() + .collect(Collectors.toMap( + EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index c22ffbb..9117cc4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; +import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -18,12 +19,28 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "rawValue", ignore = true) + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); + + /// Converts a domain property value (carried as [Object] to preserve the + /// original JSON type) into its canonical String representation for storage. + @Named("propertyValueToString") + default String propertyValueToString(Object value) { + return value == null ? null : String.valueOf(value); + } + + /// Promotes a persisted String value to the domain [Object] representation. + /// Persistence is the source of truth for textual storage; richer typing + /// (Number/Boolean) is reconstructed by the API output mapper using the template. + @Named("propertyValueFromString") + default Object propertyValueFromString(String value) { + return value; + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index a0a2d15..e5a14d3 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -23,12 +23,15 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityService Tests") @@ -37,9 +40,15 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + @Mock private EntityValidationService entityValidationService; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @InjectMocks private EntityService entityService; @@ -87,10 +96,10 @@ void shouldReturnEntityByTemplateAndIdentifier() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityTemplateValidationService).checkTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } @@ -101,38 +110,43 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { .thenReturn(Optional.empty()); assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); } @Test @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityValidationService, entityRepository); - inOrder.verify(entityValidationService).checkTemplateExist("web-service"); - inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); - inOrder.verify(entityValidationService).validateEntity(entity); + InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); + inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); + inOrder.verify(entityValidationService).checkUniqueness(entity); + inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); } @Test @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("web-service"); - verify(entityValidationService).checkEntityAlreadyExist(entity); + verify(entityTemplateRepository).findByIdentifier("web-service"); + verify(entityValidationService).checkUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -140,16 +154,14 @@ void shouldNotSaveWhenEntityAlreadyExists() { @DisplayName("Should stop immediately when template does not exist") void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); - org.mockito.Mockito.doThrow(templateNotFound) - .when(entityValidationService) - .checkTemplateExist("missing-template"); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("missing-template"); - verifyNoInteractions(entityRepository); + verify(entityTemplateRepository).findByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); } private Entity entity(String templateIdentifier, String identifier, String name) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 02c7116..8b719e0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,9 +20,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; @@ -37,7 +30,6 @@ import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; @ExtendWith(MockitoExtension.class) @@ -47,8 +39,6 @@ class EntityValidationServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private PropertyValidationService propertyValidationService; @@ -56,23 +46,6 @@ class EntityValidationServiceTest { @InjectMocks private EntityValidationService entityValidationService; - @Test - @DisplayName("Should pass checkTemplateExist when template exists") - void shouldPassCheckTemplateExistWhenTemplateExists() { - when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); - - assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); - } - - @Test - @DisplayName("Should throw checkTemplateExist when template does not exist") - void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { - when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); - - assertThrows(EntityTemplateNotFoundException.class, - () -> entityValidationService.checkTemplateExist("missing-template")); - } - @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { @@ -80,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); } @Test @@ -88,22 +61,14 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } - @Test - @DisplayName("Should throw when template is missing during validateEntity") - void shouldThrowWhenTemplateMissingDuringValidateEntity() { - var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); - } @Test - @DisplayName("Should aggregate entity, property, relation, required and rule violations") + @DisplayName("Should aggregate property requirements and rule violations") void shouldAggregateAllViolationsDuringValidateEntity() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), @@ -129,35 +94,24 @@ void shouldAggregateAllViolationsDuringValidateEntity() { List.of(requiredDefinition, portDefinition), List.of()); - var mockedRelation = org.mockito.Mockito.mock(Relation.class); - when(mockedRelation.name()).thenReturn(" "); - when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); - var entity = entity( "web-service", - " ", - " ", + " ", // Blank identifier (handled by Jakarta, not this service) + " ", // Blank name (handled by Jakarta, not this service) List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of(mockedRelation)); + List.of()); // No relations - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); - assertEquals(8, exception.getViolations().size()); - assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); - assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); - assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); - assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); - assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); - assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + // Expecting exactly 2 errors: the missing required property, and the invalid port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); } @Test @@ -186,13 +140,11 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), null); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @Test @@ -216,14 +168,42 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); verifyNoInteractions(propertyValidationService); } + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition( + UUID.randomUUID(), + "versionCode", + "Version Code as String", + PropertyType.STRING, + false, + null + ); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(stringDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), + null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + private Entity entity( String templateIdentifier, String identifier, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 2ffdef8..14ed3f6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -30,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null, null); + var violations = service.validatePropertyValue(definition, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -40,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello", "hello"); + var violations = service.validatePropertyValue(definition, "hello"); assertEquals(List.of(), violations); } @@ -51,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev", "dev"); + var violations = service.validatePropertyValue(definition, "dev"); assertEquals(List.of(), violations); } @@ -62,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab", "ab"); + var violations = service.validatePropertyValue(definition, "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -73,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -84,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc", "abc"); + var violations = service.validatePropertyValue(definition, "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -95,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345", "12345"); + var violations = service.validatePropertyValue(definition, "12345"); assertEquals(List.of(), violations); } @@ -106,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -117,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active", "active"); + var violations = service.validatePropertyValue(definition, "active"); assertEquals(List.of(), violations); } @@ -128,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything", "anything"); + var violations = service.validatePropertyValue(definition, "anything"); assertEquals(List.of(), violations); } @@ -139,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -150,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com"); assertEquals(List.of(), violations); } @@ -161,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -172,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -183,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA", "AA"); + var violations = service.validatePropertyValue(definition, "AA"); assertEquals(4, violations.size()); } @@ -195,33 +195,12 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc", "abc"); - var violations2 = service.validatePropertyValue(definition, "def", "def"); + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } - - @Test - @DisplayName("Should report type mismatch when a number is sent for a STRING property") - void shouldReportTypeMismatchWhenNumberSentForString() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("label", PropertyType.STRING, rules); - - var violations = service.validatePropertyValue(definition, "12", 12); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } - - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") - void shouldReportTypeMismatchWhenBooleanSentForString() { - var definition = propertyDefinition("label", PropertyType.STRING, null); - - var violations = service.validatePropertyValue(definition, "true", true); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } } @Nested @@ -233,7 +212,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -243,7 +222,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(), violations); } @@ -254,7 +233,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50", 50); + var violations = service.validatePropertyValue(definition, "50"); assertEquals(List.of(), violations); } @@ -265,7 +244,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3", 3); + var violations = service.validatePropertyValue(definition, "3"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -276,7 +255,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15", 15); + var violations = service.validatePropertyValue(definition, "15"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -288,7 +267,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7", 7); + var violations = service.validatePropertyValue(definition, "7"); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -300,7 +279,7 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5", 99.5); + var violations = service.validatePropertyValue(definition, "99.5"); assertEquals(List.of(), violations); } @@ -310,7 +289,7 @@ void shouldAcceptDecimalNumberValues() { void shouldReportTypeMismatchWhenBooleanSentForNumber() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true", true); + var violations = service.validatePropertyValue(definition, "true"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } @@ -324,9 +303,8 @@ class BooleanValidationTests { @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, value, originalValue); + var violations = service.validatePropertyValue(definition, value); assertEquals(List.of(), violations); } @@ -336,7 +314,7 @@ void shouldAcceptValidBooleanValues(String value) { void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes", "yes"); + var violations = service.validatePropertyValue(definition, "yes"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @@ -346,7 +324,7 @@ void shouldReportTypeMismatchForInvalidBoolean() { void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 3433491..f7be973 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -25,12 +25,12 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; From 6f466d606d9f820abdeea80ba4fcf6c95d2b2089 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:32:06 +0200 Subject: [PATCH 07/27] feat(core): fix end of file --- .../property/PropertyDefinitionRulesConflictException.java | 2 +- .../entity_template/EntityTemplateValidationService.java | 2 +- .../entity_template/PropertyDefinitionValidationService.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index f68a840..3ce489e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -22,4 +22,4 @@ public PropertyDefinitionRulesConflictException(String propertyName, PropertyTyp super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } -} \ No newline at end of file +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 34aab2f..980a95c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -115,4 +115,4 @@ public void validatePropertyRules(EntityTemplate entityTemplate) { } } -} \ No newline at end of file +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index cebf279..a35f88d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -294,4 +294,4 @@ private void validateRegexPattern(String propertyName, String regexPattern) { } } -} \ No newline at end of file +} From 6db02d48689ffb48d39ab2153a0c5aa65f9487a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 6 May 2026 11:34:39 +0200 Subject: [PATCH 08/27] feat(core): call entity template service in entity service --- .../domain/port/EntityRepositoryPort.java | 2 +- .../domain/service/entity/EntityService.java | 18 +++---- .../entity/EntityValidationService.java | 5 +- .../persistence/PostgresEntityAdapter.java | 7 +-- .../service/entity/EntityServiceTest.java | 52 +++++++++++-------- .../entity/EntityValidationServiceTest.java | 4 +- 6 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 0b2c4b8..7f84d5a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -32,7 +32,7 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 4fa2da2..3350bdd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,7 +2,6 @@ import java.util.List; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -10,16 +9,18 @@ import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + import jakarta.transaction.Transactional; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -37,9 +38,9 @@ @RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; /// Retrieves entities filtered by template with existence validation. /// @@ -52,9 +53,9 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) - .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } /// Provides lightweight entity summaries for efficient bulk operations. @@ -100,9 +101,8 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - entityValidationService.checkUniqueness(entity); + EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateUniqueness(entity); entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 4902016..b926db7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -7,7 +7,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -19,6 +18,8 @@ import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import lombok.AllArgsConstructor; + /// Domain validator for [Entity] aggregates. /// /// Validation pipeline: @@ -86,7 +87,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkUniqueness(final Entity entity) { + void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 0319c66..2325b0f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -4,7 +4,6 @@ import java.util.Optional; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -15,6 +14,8 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -45,9 +46,9 @@ public Optional findByTemplateIdentifierAndName(String templateIdentifie } @Override - public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return Optional.of(pageableEntity.map(mapper::toDomain)); + return pageableEntity.map(mapper::toDomain); } @Override diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index e5a14d3..65b9932 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -31,6 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -49,6 +51,9 @@ class EntityServiceTest { @Mock private EntityTemplateValidationService entityTemplateValidationService; + @Mock + private EntityTemplateService entityTemplateService; + @InjectMocks private EntityService entityService; @@ -59,7 +64,7 @@ void shouldReturnEntitiesByTemplateIdentifier() { var entity = entity("template-a", "entity-a", "Entity A"); var page = new PageImpl<>(List.of(entity)); - when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); @@ -67,15 +72,17 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - @Test - @DisplayName("Should throw when template has no entities page") - void shouldThrowWhenTemplatePageNotFound() { - var pageable = Pageable.ofSize(10); - when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + // @Test + // @DisplayName("Should throw when template has no entities page") + // void shouldThrowWhenTemplatePageNotFound() { + // var pageable = Pageable.ofSize(10); + // when(entityRepository.findByTemplateIdentifier("missing-template", + // pageable)).thenReturn(Optional.empty()); - assertThrows(EntityTemplateNotFoundException.class, - () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); - } + // assertThrows(EntityTemplateNotFoundException.class, + // () -> entityService.getEntitiesByTemplateIdentifier(pageable, + // "missing-template")); + // } @Test @DisplayName("Should return entity summaries by identifiers") @@ -117,17 +124,18 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); - inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); - inOrder.verify(entityValidationService).checkUniqueness(entity); + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateUniqueness(entity); inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); @@ -137,16 +145,17 @@ void shouldCreateEntityWhenValidationsPass() { @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("web-service"); - verify(entityValidationService).checkUniqueness(entity); + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -155,11 +164,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("missing-template"); + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); verifyNoInteractions(entityValidationService); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 8b719e0..6b00a96 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -53,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); } @Test @@ -61,7 +61,7 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } From a1212e0b2ce74b771474944ef2801a450e8a60d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 11 May 2026 16:13:36 +0200 Subject: [PATCH 09/27] feat(core): update the validate template methods calls --- .../idp_core/domain/service/entity/EntityService.java | 6 +++--- .../idp_core/domain/service/entity/EntityServiceTest.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3350bdd..7da8ca2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -15,7 +15,7 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; @@ -53,7 +53,7 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); } @@ -81,7 +81,7 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 65b9932..6699c56 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -32,7 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -106,7 +106,7 @@ void shouldReturnEntityByTemplateAndIdentifier() { var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityTemplateValidationService).checkTemplateExists("web-service"); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } From 30f86d601c69a17844519c029e013c40caf747d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 13 May 2026 18:01:14 +0200 Subject: [PATCH 10/27] feat(core): update the validate template methods calls --- ...sql => V3_4__change_entity_identifier_unique_to_composite.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V3_3__change_entity_identifier_unique_to_composite.sql => V3_4__change_entity_identifier_unique_to_composite.sql} (100%) diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql similarity index 100% rename from src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql rename to src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql From 5f12fec7209dd3b7caa752c119e4c9816d1b5882 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 18 May 2026 11:45:49 +0200 Subject: [PATCH 11/27] feat(core): fix review --- .../domain/model/entity/Property.java | 2 +- .../domain/service/entity/EntityService.java | 3 +- .../entity/EntityValidationService.java | 22 ++++++------- .../adapters/api/dto/in/EntityDtoIn.java | 2 +- .../api/mapper/entity/EntityDtoInMapper.java | 2 +- .../mapper/EntityPersistenceMapper.java | 17 ---------- .../service/entity/EntityServiceTest.java | 22 ++----------- .../entity/EntityValidationServiceTest.java | 32 ++++++++++++++----- 8 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 7850124..5e7281e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -29,6 +29,6 @@ public record Property( @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - Object value + String value ) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 7da8ca2..72e40ad 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -102,8 +102,7 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi @Transactional public Entity createEntity(@Valid Entity entity) { EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateUniqueness(entity); - entityValidationService.validateEntity(entity, template); + entityValidationService.validateForCreation(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index b926db7..8143e6c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -44,22 +44,17 @@ public class EntityValidationService { /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateEntity(Entity entity, EntityTemplate template) { - Violations violations = new Violations(); - validateAgainstTemplate(template, entity.properties(), violations); - - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); - } + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); } /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, - List properties, - Violations violations) { + List properties) { + Violations violations = new Violations(); List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() .filter(p -> p.name() != null) @@ -69,7 +64,7 @@ private void validateAgainstTemplate(EntityTemplate template, Property property = propertiesByName.get(definition.name()); boolean missing = property == null || property.value() == null - || (property.value() instanceof String s && s.isBlank()); + || (property.value().isBlank()); if (missing) { if (definition.required()) { @@ -82,12 +77,15 @@ private void validateAgainstTemplate(EntityTemplate template, .validatePropertyValue(definition, property.value()) .forEach(violations::add); } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } } /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void validateUniqueness(final Entity entity) { + private void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 0531655..7587711 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -50,7 +50,7 @@ public class EntityDtoIn { private String identifier; @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + private Map properties; @Valid @Schema(description = FIELD_ENTITY_RELATIONS) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 45dc45a..5548ec0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -40,7 +40,7 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> new Property( + .map((Map.Entry entry) -> new Property( null, entry.getKey(), entry.getValue() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 9117cc4..cc7edac 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -19,28 +19,11 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); - - /// Converts a domain property value (carried as [Object] to preserve the - /// original JSON type) into its canonical String representation for storage. - @Named("propertyValueToString") - default String propertyValueToString(Object value) { - return value == null ? null : String.valueOf(value); - } - - /// Promotes a persisted String value to the domain [Object] representation. - /// Persistence is the source of truth for textual storage; richer typing - /// (Number/Boolean) is reconstructed by the API output mapper using the template. - @Named("propertyValueFromString") - default Object propertyValueFromString(String value) { - return value; - } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 6699c56..22b4cb9 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -31,7 +31,6 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -42,8 +41,6 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private EntityValidationService entityValidationService; @@ -72,18 +69,6 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - // @Test - // @DisplayName("Should throw when template has no entities page") - // void shouldThrowWhenTemplatePageNotFound() { - // var pageable = Pageable.ofSize(10); - // when(entityRepository.findByTemplateIdentifier("missing-template", - // pageable)).thenReturn(Optional.empty()); - - // assertThrows(EntityTemplateNotFoundException.class, - // () -> entityService.getEntitiesByTemplateIdentifier(pageable, - // "missing-template")); - // } - @Test @DisplayName("Should return entity summaries by identifiers") void shouldReturnEntitySummariesByIdentifiers() { @@ -135,8 +120,7 @@ void shouldCreateEntityWhenValidationsPass() { InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateUniqueness(entity); - inOrder.verify(entityValidationService).validateEntity(entity, template); + inOrder.verify(entityValidationService).validateForCreation(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); } @@ -150,12 +134,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateUniqueness(entity); + verify(entityValidationService).validateForCreation(entity, template); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6b00a96..6411bd6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -49,19 +50,34 @@ class EntityValidationServiceTest { @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); } @Test @DisplayName("Should not query repository when identifier is null") void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @@ -69,7 +85,7 @@ void shouldNotQueryRepositoryWhenIdentifierIsNull() { @Test @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateEntity() { + void shouldAggregateAllViolationsDuringValidateForCreation() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), "port", @@ -104,7 +120,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); // Expecting exactly 2 errors: the missing required property, and the invalid port value. assertEquals(2, exception.getViolations().size()); @@ -116,7 +132,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { @Test @DisplayName("Should validate entity successfully when no violations") - void shouldValidateEntitySuccessfullyWhenNoViolations() { + void shouldValidateForCreationSuccessfullyWhenNoViolations() { var versionDefinition = new PropertyDefinition( UUID.randomUUID(), "version", @@ -143,7 +159,7 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @@ -168,7 +184,7 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verifyNoInteractions(propertyValidationService); } @@ -200,7 +216,7 @@ void shouldValidateStringPropertyWithNumericStringValue() { null); when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); } From ecc92b2202fe6a2b6d2c0b1f4e4742b30434309e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 11:41:01 +0200 Subject: [PATCH 12/27] feat(core): add a entity graph service and endpoint --- .../idp_core/domain/model/entity/Entity.java | 2 - .../model/entity/EntityCompositeKey.java | 36 +++++ .../model/entity_graph/EntityGraphNode.java | 26 ++++ .../entity_graph/EntityGraphRelation.java | 21 +++ .../port/EntityGraphRepositoryPort.java | 37 +++++ .../entity_graph/EntityGraphService.java | 144 ++++++++++++++++++ .../api/configuration/SwaggerDescription.java | 13 ++ .../api/controller/EntityGraphController.java | 78 ++++++++++ .../dto/out/entity/EntityGraphNodeDtoOut.java | 29 ++++ .../out/entity/EntityGraphRelationDtoOut.java | 26 ++++ .../entity/RelationAsTargetSummaryDtoOut.java | 13 ++ .../entity/EntityGraphDtoOutMapper.java | 50 ++++++ .../PostgresEntityGraphAdapter.java | 69 +++++++++ .../repository/JpaEntityRepository.java | 60 ++++++++ .../repository/JpaRelationRepository.java | 4 +- 15 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2b77241..ab10abe 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,8 +7,6 @@ import java.util.List; import java.util.UUID; -import org.springframework.validation.annotation.Validated; - import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java new file mode 100644 index 0000000..30a0f99 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.Objects; + +/** + * Composite key for uniquely identifying an entity across templates. + * Since the same identifier can exist in different templates, we need both fields. + */ +public record EntityCompositeKey(String templateIdentifier, String identifier) { + public static EntityCompositeKey fromString(String compositeKey) { + String[] parts = compositeKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + return new EntityCompositeKey(parts[0], parts[1]); + } + + @Override + public String toString() { + return templateIdentifier + ":" + identifier; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EntityCompositeKey that = (EntityCompositeKey) o; + return Objects.equals(templateIdentifier, that.templateIdentifier) && + Objects.equals(identifier, that.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(templateIdentifier, identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java new file mode 100644 index 0000000..9348a38 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// A node in the entity relationship graph, containing summary information +/// and its resolved relations (recursively up to a configurable depth). +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs +/// - Understanding relationship chains between entities +/// - Providing a hierarchical view of entity connections +/// +/// @param summary the lightweight entity identification data +/// @param relations the resolved outbound relations with their target graph nodes +/// @param relationsAsTarget incoming relations where this entity is the target +public record EntityGraphNode( + String identifier, + String name, + List relations, + List relationsAsTarget +) { + public EntityGraphNode { + relations = relations != null ? List.copyOf(relations) : List.of(); + relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java new file mode 100644 index 0000000..d770639 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// Represents a single named relation in the entity graph with its resolved target nodes. +/// +/// **Business purpose:** +/// - Groups related entities under their relation name +/// - Enables graph traversal by relation type +/// +/// @param name the relation name as defined in the entity template +/// @param targetTemplateIdentifier the template identifier of the target entities +/// @param targets the resolved target entity graph nodes (recursively populated up to depth) +public record EntityGraphRelation( + String name, + List targets +) { + public EntityGraphRelation { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java new file mode 100644 index 0000000..e1ae73f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Map; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; + +/// Driven port defining the contract for entity relationship graph retrieval. +/// +/// Separated from [EntityRepositoryPort] to follow the Interface Segregation Principle: +/// graph traversal is a distinct read concern backed by recursive CTE queries, +/// with no overlap with standard CRUD operations. +/// +/// **Contract expectations for implementations:** +/// - Must traverse both outbound and inbound relations up to the requested depth +/// - Must return the root entity itself as part of the result map +/// - Must return an empty map when the root entity does not exist +/// - Depth must be clamped server-side; implementations may ignore values outside a valid range +/// +/// **Transaction behavior:** Implementations should use a read-only transaction +/// as this port performs no write operations. +public interface EntityGraphRepositoryPort { + + /// Fetches all entities in the relationship graph rooted at the given composite key. + /// + /// Uses a recursive CTE to traverse both outbound and inbound relations up to the + /// specified depth, then batch-loads all entities in a minimal number of queries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity within its template + /// @param depth the maximum traversal depth (1-10) + /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found + Map findEntityGraph( + String templateIdentifier, + String entityIdentifier, + int depth); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java new file mode 100644 index 0000000..46ca3f8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -0,0 +1,144 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service for building entity relationship graphs. +/// +/// Resolves an entity's outbound and inbound relations recursively up to a configurable depth, +/// returning a tree of [EntityGraphNode] records containing summary information +/// for each connected entity. +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs in the catalog UI +/// - Understanding relationship chains (e.g., service → database → infrastructure) +/// - Providing hierarchical views for impact analysis and change propagation +/// +/// **Design decisions:** +/// - Uses depth-limited traversal to prevent unbounded recursion +/// - Optimized with recursive CTE and batch loading to minimize database queries +/// - Does not detect cycles — relies on depth limit to terminate +@Service +@RequiredArgsConstructor +public class EntityGraphService { + + private static final int MAX_DEPTH = 10; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + + /// Builds the relationship graph for an entity starting from its composite key. + /// + /// **Optimization:** Uses a recursive CTE to fetch all entities in the graph in 2 queries + /// (1 for composite key pairs, 1 for batch loading), regardless of depth. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @return the root graph node with resolved relations + /// @throws EntityNotFoundException when no entity matches the given identifiers + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth) { + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + // Verify root entity exists before fetching the graph + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + // Optimized batch fetch: load all entities in the graph keyed by composite key + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); + + // Build the graph from pre-loaded entities (no more database calls) + return buildGraphNode(rootKey, entityMap, effectiveDepth); + } + + /// Builds a graph node from a pre-loaded entity map (no database calls). + /// Recursively resolves both outbound and inbound relations from the cached entities. + private EntityGraphNode buildGraphNode(EntityCompositeKey key, + Map entityMap, + int remainingDepth) { + Entity entity = entityMap.get(key); + if (entity == null) { + return new EntityGraphNode(key.identifier(), key.identifier(), List.of(), List.of()); + } + + if (remainingDepth <= 0) { + return new EntityGraphNode(entity.identifier(), entity.name(), List.of(), List.of()); + } + + // Resolve outbound relations from pre-loaded entities + List outboundRelations = entity.relations().stream() + .map(relation -> new EntityGraphRelation( + relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> { + // Relations only store identifier; look up by identifier across all entries + EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); + return buildGraphNode(targetKey, entityMap, remainingDepth - 1); + }) + .toList() + )) + .toList(); + + // Resolve inbound relations from pre-loaded entities + List inboundRelations = buildRelationsAsTargetFromMap( + entity.identifier(), entityMap, remainingDepth - 1); + + return new EntityGraphNode(entity.identifier(), entity.name(), outboundRelations, inboundRelations); + } + + /// Looks up a composite key from the map by identifier alone. + /// Falls back to a synthetic key if no match is found (entity not in graph). + private EntityCompositeKey findKeyByIdentifier(String identifier, Map entityMap) { + return entityMap.keySet().stream() + .filter(k -> k.identifier().equals(identifier)) + .findFirst() + .orElse(new EntityCompositeKey("", identifier)); + } + + /// Builds incoming relations (where this entity is the target) from the pre-loaded entity map. + /// Scans all entities to find relations pointing to this entity. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, + int remainingDepth) { + Map> sourcesByRelationName = new java.util.HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + Entity sourceEntity = entry.getValue(); + for (Relation relation : sourceEntity.relations()) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + sourcesByRelationName + .computeIfAbsent(relation.name(), k -> new java.util.ArrayList<>()) + .add(entry.getKey()); + } + } + } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation( + e.getKey(), + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth)) + .toList() + )) + .toList(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index d645725..b7f85ec 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -148,4 +148,17 @@ public class SwaggerDescription { public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + + // --- Entity Graph descriptions --- + public static final String ENDPOINT_GET_ENTITY_GRAPH_SUMMARY = "Get entity relationship graph"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION = "Retrieves the entity relationship graph starting from the specified entity, resolving outbound relations recursively up to the requested depth."; + public static final String RESPONSE_ENTITY_GRAPH_SUCCESS = "Entity graph successfully retrieved"; + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String ENTITY_GRAPH_NODE_DESCRIPTION = "A node in the entity relationship graph"; + public static final String ENTITY_GRAPH_SUMMARY_DESCRIPTION = "Summary information identifying the entity"; + public static final String ENTITY_GRAPH_RELATIONS_DESCRIPTION = "Resolved outbound relations with target entity nodes"; + public static final String ENTITY_GRAPH_RELATION_NAME_DESCRIPTION = "The relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; + public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; + public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java new file mode 100644 index 0000000..727b963 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -0,0 +1,78 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static org.springframework.http.HttpStatus.OK; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; + +/// REST controller for entity relationship graph operations. +/// +/// Provides endpoints to retrieve hierarchical relationship graphs starting from +/// a specified entity, enabling visualization of entity dependencies and connections. +@RestController +@RequestMapping("/api/v1/entities") +@RequiredArgsConstructor +@Tag(name = "Entity Graph", description = "Entity relationship graph operations") +public class EntityGraphController { + + private final EntityGraphService entityGraphService; + + /// Retrieves the entity relationship graph starting from the specified entity. + /// + /// Resolves outbound relations recursively up to the requested depth, + /// returning a tree structure with entity summary information at each node. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @return the root graph node with resolved relations + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") + @ResponseStatus(OK) + @Operation( + summary = ENDPOINT_GET_ENTITY_GRAPH_SUMMARY, + description = ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION, + responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_SUCCESS, + content = @Content(schema = @Schema(implementation = EntityGraphNodeDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + public EntityGraphNodeDtoOut getEntityGraph( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth) { + + EntityGraphNode graphNode = entityGraphService.getEntityGraph( + templateIdentifier, entityIdentifier, depth); + + return EntityGraphDtoOutMapper.toDto(graphNode); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 0000000..5d159bf --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_DESCRIPTION; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a node in the entity relationship graph. +/// +/// Contains summary information about the entity and its resolved outbound relations +/// grouped by relation name, and incoming relations where this entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeDtoOut( + + String identifier, + String name, + + @Schema(description = ENTITY_GRAPH_RELATIONS_DESCRIPTION) + Map> relations, + + @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) + Map> relationsAsTarget +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 0000000..55f0794 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; + +/// Output DTO representing a single named relation in the entity graph. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphRelationDtoOut( + + @Schema(description = ENTITY_GRAPH_RELATION_NAME_DESCRIPTION) + String name, + + @Schema(description = ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION) + String targetTemplateIdentifier, + + @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) + List targets +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java new file mode 100644 index 0000000..1e734bd --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Output DTO representing an incoming relationship where the entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record RelationAsTargetSummaryDtoOut( + String targetEntityIdentifier, + String relationName, + String sourceEntityIdentifier, + String sourceEntityName +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 0000000..8b3e6be --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java @@ -0,0 +1,50 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; + +/// Mapper for converting domain [EntityGraphNode] to its API output DTO +/// representation. +/// +/// Uses Record Patterns for recursive tree mapping since MapStruct does not +/// handle recursive structures cleanly. +public final class EntityGraphDtoOutMapper { + + private EntityGraphDtoOutMapper() { + // Utility class + } + + /// Maps a domain graph node to its DTO representation. + /// + /// @param node the domain graph node + /// @return the output DTO + public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { + if (node == null) { + return null; + } + return new EntityGraphNodeDtoOut( + node.identifier(), node.name(), + mapRelations(node.relations()), + mapRelations(node.relationsAsTarget())); + } + + private static Map> mapRelations(List relations) { + if (relations == null || relations.isEmpty()) { + return Map.of(); + } + return relations.stream() + .collect(Collectors.toMap( + EntityGraphRelation::name, + relation -> relation.targets().stream() + .map(EntityGraphDtoOutMapper::toDto) + .toList(), + (existing, replacement) -> existing, + LinkedHashMap::new)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java new file mode 100644 index 0000000..8527027 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -0,0 +1,69 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter dedicated to entity relationship graph traversal. +/// +/// Separated from [PostgresEntityAdapter] because graph queries use a distinct +/// recursive CTE strategy that has no overlap with standard CRUD operations, +/// following the Interface Segregation Principle. +/// +/// **Query strategy:** +/// 1. One recursive CTE query to collect all (identifier, template_identifier) pairs in the graph. +/// 2. One batch query to load entities with their relations (avoids N+1). +/// 3. One batch query to load properties separately (avoids MultipleBagFetchException). +@Component +@RequiredArgsConstructor +public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { + + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; + + @Override + public Map findEntityGraph( + String templateIdentifier, + String entityIdentifier, + int depth) { + // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( + templateIdentifier, entityIdentifier, depth); + + if (graphPairs.isEmpty()) { + return Map.of(); + } + + // Step 2: extract unique identifiers for batch loading + List identifiers = graphPairs.stream() + .map(pair -> (String) pair[0]) + .distinct() + .toList(); + + // Step 3: batch-load entities with relations, then properties in separate queries + // to avoid Hibernate's MultipleBagFetchException + List jpaEntities = + jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + + // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream() + .map(mapper::toDomain) + .collect(Collectors.toMap( + e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), + Function.identity() + )); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 97675e9..fb94167 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -27,6 +27,8 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByIdentifier(String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); @@ -56,4 +58,62 @@ WHERE r IN ( void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); + + /// Batch fetch entities by identifiers with eager loading of relations and properties. + /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. + /// First fetches entities with relations, then fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations(@Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. + /// This is called after findAllByIdentifierInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties(@Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, + @Param("depth") int depth); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 6c2c543..57c0c66 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -15,7 +15,9 @@ public interface JpaRelationRepository extends JpaRepository { @Query(""" - SELECT tei AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, e.name AS sourceEntityName + SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( + tei, r.name, e.identifier, e.name + ) FROM EntityJpaEntity e JOIN e.relations r JOIN r.targetEntityIdentifiers tei From 1293b541033d9c30400ff79182a3aa2004c93017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 12:06:00 +0200 Subject: [PATCH 13/27] feat(core): add a entity graph service and endpoint --- .../dto/out/entity/EntityGraphNodeDtoOut.java | 8 +- .../out/entity/EntityGraphRelationDtoOut.java | 15 +- .../entity_graph/EntityGraphServiceTest.java | 284 ++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java index 5d159bf..980fb26 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -26,4 +26,10 @@ public record EntityGraphNodeDtoOut( @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) Map> relationsAsTarget -) {} +) { + /// Defensive copies prevent external mutation of the mutable Map collections + public EntityGraphNodeDtoOut { + relations = relations != null ? Map.copyOf(relations) : Map.of(); + relationsAsTarget = relationsAsTarget != null ? Map.copyOf(relationsAsTarget) : Map.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java index 55f0794..df12629 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; + import java.util.List; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; @@ -7,10 +11,6 @@ import io.swagger.v3.oas.annotations.media.Schema; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; - /// Output DTO representing a single named relation in the entity graph. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphRelationDtoOut( @@ -23,4 +23,9 @@ public record EntityGraphRelationDtoOut( @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) List targets -) {} +) { + /// Defensive copy prevents external mutation of the mutable List collection + public EntityGraphRelationDtoOut { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java new file mode 100644 index 0000000..0b07bd0 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -0,0 +1,284 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityGraphService Tests") +class EntityGraphServiceTest { + + private static final String TEMPLATE = "web-service"; + private static final String DB_TEMPLATE = "database"; + private static final String CACHE_TEMPLATE = "cache"; + private static final String INFRA_TEMPLATE = "infrastructure"; + private static final int DEFAULT_DEPTH = 3; + + @Mock + private EntityRepositoryPort entityRepositoryPort; + + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; + + @InjectMocks + private EntityGraphService entityGraphService; + + // --- Fixtures --- + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } + + private Entity entityWithRelations(String templateIdentifier, String identifier, String name, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); + } + + private Relation relation(String name, String targetTemplateIdentifier, List targetIdentifiers) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targetIdentifiers); + } + + private EntityCompositeKey key(String templateIdentifier, String identifier) { + return new EntityCompositeKey(templateIdentifier, identifier); + } + + // --- Tests --- + + @Nested + @DisplayName("getEntityGraph — root entity not found") + class RootEntityNotFound { + + @Test + @DisplayName("Should throw EntityNotFoundException when root entity does not exist") + void shouldThrowWhenRootEntityNotFound() { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH)) + .isInstanceOf(EntityNotFoundException.class); + + verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); + verifyNoInteractions(entityGraphRepositoryPort); + } + } + + @Nested + @DisplayName("getEntityGraph — single root, no relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return a leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); + } + } + + @Nested + @DisplayName("getEntityGraph — outbound relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relations to graph nodes") + void shouldResolveOutboundRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().getFirst().name()).isEqualTo("uses"); + assertThat(result.relations().getFirst().targets()).hasSize(1); + assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("postgres"); + } + + @Test + @DisplayName("Should create a fallback node when relation target is not in the graph map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + // Simulates a target entity outside the loaded depth — still produces a placeholder node + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("unknown-db")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(1); + // Fallback node uses identifier as both id and name when entity is not in map + assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("unknown-db"); + } + } + + @Nested + @DisplayName("getEntityGraph — inbound relations") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations for entities that are targeted by others") + void shouldResolveInboundRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) + .thenReturn(Optional.of(db)); + when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH); + + // postgres is targeted by api via "uses" + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().getFirst().name()).isEqualTo("uses"); + assertThat(result.relationsAsTarget().getFirst().targets().getFirst().identifier()).isEqualTo("api"); + } + } + + @Nested + @DisplayName("getEntityGraph — depth clamping") + class DepthClamping { + + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 0); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); + } + + @Test + @DisplayName("Should clamp depth above 10 to 10") + void shouldClampDepthAboveTen() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 99); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); + } + } + + @Nested + @DisplayName("getEntityGraph — depth limit stops recursion") + class DepthLimit { + + @Test + @DisplayName("Should return a leaf node for targets at the depth boundary") + void shouldReturnLeafNodeAtDepthBoundary() { + // api --uses--> postgres --runs-on--> server-1 + // At depth=1: postgres node is resolved but its own relations are NOT expanded + var server = entity(INFRA_TEMPLATE, "server-1", "Server 1"); + var db = entityWithRelations(DB_TEMPLATE, "postgres", "Postgres DB", + List.of(relation("runs-on", INFRA_TEMPLATE, List.of("server-1")))); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db, + key(INFRA_TEMPLATE, "server-1"), server + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1); + + // postgres node is included but its child relations are empty (remaining depth = 0) + var dbNode = result.relations().getFirst().targets().getFirst(); + assertThat(dbNode.identifier()).isEqualTo("postgres"); + assertThat(dbNode.relations()).isEmpty(); + } + } + + @Nested + @DisplayName("getEntityGraph — multiple outbound relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple named relation types correctly") + void shouldResolveMultipleNamedRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var cache = entity(CACHE_TEMPLATE, "redis", "Redis Cache"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", DB_TEMPLATE, List.of("postgres")), + relation("uses-cache", CACHE_TEMPLATE, List.of("redis")) + )); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db, + key(CACHE_TEMPLATE, "redis"), cache + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(2); + var relationNames = result.relations().stream().map(r -> r.name()).toList(); + assertThat(relationNames).containsExactlyInAnyOrder("uses-db", "uses-cache"); + } + } +} From 7411267d36e7e7e78fa24b95962a01eb281e30a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 16:54:45 +0200 Subject: [PATCH 14/27] feat(core): add a entity graph service and endpoint --- .../model/entity_graph/EntityGraphNode.java | 2 + .../entity_graph/EntityGraphService.java | 6 +- .../api/configuration/SwaggerDescription.java | 15 +++ .../api/controller/EntityGraphController.java | 39 ++++++ .../dto/out/entity/EntityGraphEdgeDtoOut.java | 31 +++++ .../dto/out/entity/EntityGraphFlatDtoOut.java | 33 +++++ .../dto/out/entity/EntityGraphNodeDtoOut.java | 1 + .../out/entity/EntityGraphNodeFlatDtoOut.java | 31 +++++ .../entity/EntityGraphDtoOutMapper.java | 2 +- .../entity/EntityGraphFlatDtoOutMapper.java | 123 ++++++++++++++++++ .../entity_graph/EntityGraphServiceTest.java | 2 - 11 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index 9348a38..2a795f5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -10,10 +10,12 @@ /// - Understanding relationship chains between entities /// - Providing a hierarchical view of entity connections /// +/// @param templateIdentifier the template identifier this entity belongs to /// @param summary the lightweight entity identification data /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target public record EntityGraphNode( + String templateIdentifier, String identifier, String name, List relations, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 46ca3f8..367e154 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -77,11 +77,11 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, int remainingDepth) { Entity entity = entityMap.get(key); if (entity == null) { - return new EntityGraphNode(key.identifier(), key.identifier(), List.of(), List.of()); + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of()); } if (remainingDepth <= 0) { - return new EntityGraphNode(entity.identifier(), entity.name(), List.of(), List.of()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of()); } // Resolve outbound relations from pre-loaded entities @@ -102,7 +102,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, List inboundRelations = buildRelationsAsTargetFromMap( entity.identifier(), entityMap, remainingDepth - 1); - return new EntityGraphNode(entity.identifier(), entity.name(), outboundRelations, inboundRelations); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index b7f85ec..67013ba 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -161,4 +161,19 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; + + // --- Entity Graph flat (nodes & edges) descriptions --- + 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 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"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 727b963..c33a558 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,10 +1,13 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; +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.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_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_GRAPH_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -18,9 +21,11 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -75,4 +80,38 @@ public EntityGraphNodeDtoOut getEntityGraph( return EntityGraphDtoOutMapper.toDto(graphNode); } + + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// Returns all entities as nodes and all directed relations as edges, following + /// the de-facto standard for frontend visualization tools such as React Flow, + /// Vis.js, and Cytoscape. Nodes are deduplicated; edges encode directionality. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @return flat DTO containing nodes and edges arrays + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph/flat") + @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 = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + public EntityGraphFlatDtoOut getEntityGraphFlat( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth) { + + EntityGraphNode graphNode = entityGraphService.getEntityGraph( + templateIdentifier, entityIdentifier, depth); + + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java new file mode 100644 index 0000000..c61800d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a directed relation edge in the flat entity graph. +/// +/// Encodes a single directional connection between two entity nodes, identified +/// by their composite-key-derived node IDs. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphEdgeDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) + String id, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) + String source, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) + String target, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) + String type +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java new file mode 100644 index 0000000..aa43eb8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODES_DESCRIPTION; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Top-level response DTO for the flat entity graph representation. +/// +/// Separates entities from their connections into two parallel collections, +/// following the de-facto standard expected by frontend visualization libraries +/// such as React Flow, Vis.js, and Cytoscape. This format avoids nesting and +/// any risk of infinite loops caused by circular relations. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) + List nodes, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) + List edges +) { + /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + edges = edges != null ? List.copyOf(edges) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java index 980fb26..43119c9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -18,6 +18,7 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeDtoOut( + String templateIdentifier, String identifier, String name, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java new file mode 100644 index 0000000..569941e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a single node in the flat entity graph. +/// +/// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect +/// entities and their relationships as separate, non-nested collections. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) + String id, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) + String label, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) + String templateIdentifier, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) + String identifier +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java index 8b3e6be..582c9f5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java @@ -29,7 +29,7 @@ public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { return null; } return new EntityGraphNodeDtoOut( - node.identifier(), node.name(), + node.templateIdentifier(), node.identifier(), node.name(), mapRelations(node.relations()), mapRelations(node.relationsAsTarget())); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java new file mode 100644 index 0000000..d8874c2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -0,0 +1,123 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.SequencedSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; + +/// Mapper for converting a recursive [EntityGraphNode] domain tree into the flat +/// nodes-and-edges representation expected by frontend visualization libraries +/// (React Flow, Vis.js, Cytoscape). +/// +/// **Design:** +/// - Traverses both `relations` (outbound) and `relationsAsTarget` (inbound) depth-first, +/// deduplicating nodes by their composite node ID (templateIdentifier:identifier). +/// - Outbound edges are emitted as `source → target`. +/// - Inbound edges (relationsAsTarget) are emitted as `source → currentNode`, preserving +/// the original direction of the relation. This is critical when the root entity has no +/// outbound relations and is only reachable as a relation target. +/// - A `SequencedSet` of visited node IDs prevents infinite loops in cyclic graphs. +/// - A `Set` of edge signatures (`source|target|label`) deduplicates edges that would +/// otherwise be emitted twice when both sides of a relation are traversed. +public final class EntityGraphFlatDtoOutMapper { + + private EntityGraphFlatDtoOutMapper() { + // Utility class — not instantiable + } + + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. + /// + /// @param root the root [EntityGraphNode] returned by the domain service + /// @return flat DTO with deduplicated nodes and directed edges + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); + } + + // Use a SequencedSet to deduplicate nodes while preserving insertion order + SequencedSet nodes = new LinkedHashSet<>(); + List edges = new ArrayList<>(); + // Tracks visited node IDs to prevent infinite loops in cyclic graphs + Set visitedNodeIds = new HashSet<>(); + // Tracks emitted edge signatures (source|target|label) to avoid duplicate edges + // when the same relation is encountered from both sides during traversal + Set emittedEdgeSignatures = new HashSet<>(); + var edgeCounter = new AtomicInteger(0); + + traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + + return new EntityGraphFlatDtoOut(List.copyOf(nodes), List.copyOf(edges)); + } + + private static void traverse( + EntityGraphNode node, + SequencedSet nodes, + List edges, + Set visitedNodeIds, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter) { + + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!visitedNodeIds.add(nodeId)) { + return; + } + + nodes.add(new EntityGraphNodeFlatDtoOut( + nodeId, node.name(), node.templateIdentifier(), node.identifier())); + + // Traverse outbound relations: emit edge from currentNode → target + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + } + } + + // Traverse inbound relations: emit edge from source → currentNode. + // This is essential when the root entity has no outbound relations and is only + // reachable as a target. Without this, traversal would stop at the root with no edges. + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + } + } + } + + /// Adds a directed edge only if it has not been emitted before, preventing duplicates + /// that arise when the same relation is encountered from both the source and the target + /// during depth-first traversal. + private static void addEdge( + List edges, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter, + String sourceId, + String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (emittedEdgeSignatures.add(signature)) { + edges.add(new EntityGraphEdgeDtoOut( + "e" + edgeCounter.incrementAndGet(), sourceId, targetId, label)); + } + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors EntityCompositeKey.toString(). + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0b07bd0..5e38977 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -65,8 +65,6 @@ private EntityCompositeKey key(String templateIdentifier, String identifier) { return new EntityCompositeKey(templateIdentifier, identifier); } - // --- Tests --- - @Nested @DisplayName("getEntityGraph — root entity not found") class RootEntityNotFound { From 7e9c55625c13320b87afa0d6a8b514138138a443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 10:51:31 +0200 Subject: [PATCH 15/27] feat(core): add a entity graph service and endpoint --- .../api/configuration/SwaggerDescription.java | 14 +---- .../api/controller/EntityGraphController.java | 53 +++---------------- .../dto/out/entity/EntityGraphNodeDtoOut.java | 36 ------------- .../out/entity/EntityGraphRelationDtoOut.java | 31 ----------- .../entity/EntityGraphDtoOutMapper.java | 50 ----------------- 5 files changed, 9 insertions(+), 175 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 67013ba..c3b229e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -149,20 +149,8 @@ public class SwaggerDescription { public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - // --- Entity Graph descriptions --- - public static final String ENDPOINT_GET_ENTITY_GRAPH_SUMMARY = "Get entity relationship graph"; - public static final String ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION = "Retrieves the entity relationship graph starting from the specified entity, resolving outbound relations recursively up to the requested depth."; - public static final String RESPONSE_ENTITY_GRAPH_SUCCESS = "Entity graph successfully retrieved"; + // --- Entity Graph (flat nodes & edges) descriptions --- public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; - public static final String ENTITY_GRAPH_NODE_DESCRIPTION = "A node in the entity relationship graph"; - public static final String ENTITY_GRAPH_SUMMARY_DESCRIPTION = "Summary information identifying the entity"; - public static final String ENTITY_GRAPH_RELATIONS_DESCRIPTION = "Resolved outbound relations with target entity nodes"; - public static final String ENTITY_GRAPH_RELATION_NAME_DESCRIPTION = "The relation name as defined in the entity template"; - public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; - public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; - public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; - - // --- Entity Graph flat (nodes & edges) descriptions --- 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"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index c33a558..872b846 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,14 +1,11 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; 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.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_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_GRAPH_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -22,9 +19,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; @@ -38,8 +33,9 @@ /// REST controller for entity relationship graph operations. /// -/// Provides endpoints to retrieve hierarchical relationship graphs starting from -/// a specified entity, enabling visualization of entity dependencies and connections. +/// Provides endpoints to retrieve flat (nodes and edges) relationship graphs +/// starting from a specified entity, suitable for frontend visualization tools +/// such as React Flow, Vis.js, and Cytoscape. @RestController @RequestMapping("/api/v1/entities") @RequiredArgsConstructor @@ -48,50 +44,17 @@ public class EntityGraphController { private final EntityGraphService entityGraphService; - /// Retrieves the entity relationship graph starting from the specified entity. - /// - /// Resolves outbound relations recursively up to the requested depth, - /// returning a tree structure with entity summary information at each node. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @return the root graph node with resolved relations - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") - @ResponseStatus(OK) - @Operation( - summary = ENDPOINT_GET_ENTITY_GRAPH_SUMMARY, - description = ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION, - responses = { - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_SUCCESS, - content = @Content(schema = @Schema(implementation = EntityGraphNodeDtoOut.class))), - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - } - ) - public EntityGraphNodeDtoOut getEntityGraph( - @PathVariable @NotBlank String templateIdentifier, - @PathVariable @NotBlank String entityIdentifier, - @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth) { - - EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth); - - return EntityGraphDtoOutMapper.toDto(graphNode); - } - /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. /// - /// Returns all entities as nodes and all directed relations as edges, following - /// the de-facto standard for frontend visualization tools such as React Flow, - /// Vis.js, and Cytoscape. Nodes are deduplicated; edges encode directionality. + /// Returns all entities as nodes and all directed relations as edges. Nodes are + /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, + /// Cytoscape, and similar frontend graph visualization libraries. /// /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) /// @return flat DTO containing nodes and edges arrays - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph/flat") + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @Operation( summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, @@ -103,7 +66,7 @@ public EntityGraphNodeDtoOut getEntityGraph( content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - public EntityGraphFlatDtoOut getEntityGraphFlat( + public EntityGraphFlatDtoOut getEntityGraph( @PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java deleted file mode 100644 index 43119c9..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_DESCRIPTION; - -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -/// Output DTO representing a node in the entity relationship graph. -/// -/// Contains summary information about the entity and its resolved outbound relations -/// grouped by relation name, and incoming relations where this entity is the target. -@JsonNaming(SnakeCaseStrategy.class) -public record EntityGraphNodeDtoOut( - - String templateIdentifier, - String identifier, - String name, - - @Schema(description = ENTITY_GRAPH_RELATIONS_DESCRIPTION) - Map> relations, - - @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) - Map> relationsAsTarget -) { - /// Defensive copies prevent external mutation of the mutable Map collections - public EntityGraphNodeDtoOut { - relations = relations != null ? Map.copyOf(relations) : Map.of(); - relationsAsTarget = relationsAsTarget != null ? Map.copyOf(relationsAsTarget) : Map.of(); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java deleted file mode 100644 index df12629..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; - -import java.util.List; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -/// Output DTO representing a single named relation in the entity graph. -@JsonNaming(SnakeCaseStrategy.class) -public record EntityGraphRelationDtoOut( - - @Schema(description = ENTITY_GRAPH_RELATION_NAME_DESCRIPTION) - String name, - - @Schema(description = ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION) - String targetTemplateIdentifier, - - @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) - List targets -) { - /// Defensive copy prevents external mutation of the mutable List collection - public EntityGraphRelationDtoOut { - targets = targets != null ? List.copyOf(targets) : List.of(); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java deleted file mode 100644 index 582c9f5..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; - -/// Mapper for converting domain [EntityGraphNode] to its API output DTO -/// representation. -/// -/// Uses Record Patterns for recursive tree mapping since MapStruct does not -/// handle recursive structures cleanly. -public final class EntityGraphDtoOutMapper { - - private EntityGraphDtoOutMapper() { - // Utility class - } - - /// Maps a domain graph node to its DTO representation. - /// - /// @param node the domain graph node - /// @return the output DTO - public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { - if (node == null) { - return null; - } - return new EntityGraphNodeDtoOut( - node.templateIdentifier(), node.identifier(), node.name(), - mapRelations(node.relations()), - mapRelations(node.relationsAsTarget())); - } - - private static Map> mapRelations(List relations) { - if (relations == null || relations.isEmpty()) { - return Map.of(); - } - return relations.stream() - .collect(Collectors.toMap( - EntityGraphRelation::name, - relation -> relation.targets().stream() - .map(EntityGraphDtoOutMapper::toDto) - .toList(), - (existing, replacement) -> existing, - LinkedHashMap::new)); - } -} From b68ba4d1a2709f7eeb73a260fc7c61890de6e75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 11:19:15 +0200 Subject: [PATCH 16/27] feat(entity-graph): add a entity graph service and endpoint add include data parameter for showing properties in node --- .../model/entity_graph/EntityGraphNode.java | 8 ++++- .../entity_graph/EntityGraphService.java | 32 +++++++++++++------ .../api/configuration/SwaggerDescription.java | 2 ++ .../api/controller/EntityGraphController.java | 8 +++-- .../out/entity/EntityGraphNodeFlatDtoOut.java | 16 ++++++++-- .../entity/EntityGraphFlatDtoOutMapper.java | 16 ++++++++-- .../entity_graph/EntityGraphServiceTest.java | 18 +++++------ 7 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index 2a795f5..fff3564 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -2,6 +2,8 @@ import java.util.List; +import com.decathlon.idp_core.domain.model.entity.Property; + /// A node in the entity relationship graph, containing summary information /// and its resolved relations (recursively up to a configurable depth). /// @@ -11,17 +13,21 @@ /// - Providing a hierarchical view of entity connections /// /// @param templateIdentifier the template identifier this entity belongs to -/// @param summary the lightweight entity identification data +/// @param identifier the business identifier of the entity +/// @param name the human-readable name of the entity +/// @param properties the entity's property instances; empty when not requested /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target public record EntityGraphNode( String templateIdentifier, String identifier, String name, + List properties, List relations, List relationsAsTarget ) { public EntityGraphNode { + properties = properties != null ? List.copyOf(properties) : List.of(); relations = relations != null ? List.copyOf(relations) : List.of(); relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 367e154..e32f1ae 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -9,6 +9,7 @@ import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; @@ -49,10 +50,13 @@ public class EntityGraphService { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's full property list; + /// when false, properties are omitted to reduce response size /// @return the root graph node with resolved relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) - public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth) { + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, + boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); // Verify root entity exists before fetching the graph @@ -67,21 +71,24 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); // Build the graph from pre-loaded entities (no more database calls) - return buildGraphNode(rootKey, entityMap, effectiveDepth); + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties); } /// Builds a graph node from a pre-loaded entity map (no database calls). /// Recursively resolves both outbound and inbound relations from the cached entities. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, - int remainingDepth) { + int remainingDepth, + boolean includeProperties) { Entity entity = entityMap.get(key); if (entity == null) { - return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of()); + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), + List.of(), List.of(), List.of()); } if (remainingDepth <= 0) { - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + List.of(), List.of(), List.of()); } // Resolve outbound relations from pre-loaded entities @@ -92,7 +99,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, .map(targetId -> { // Relations only store identifier; look up by identifier across all entries EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); - return buildGraphNode(targetKey, entityMap, remainingDepth - 1); + return buildGraphNode(targetKey, entityMap, remainingDepth - 1, includeProperties); }) .toList() )) @@ -100,9 +107,13 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Resolve inbound relations from pre-loaded entities List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1); + entity.identifier(), entityMap, remainingDepth - 1, includeProperties); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), outboundRelations, inboundRelations); + // Include properties only when explicitly requested to keep responses lean + List properties = includeProperties ? entity.properties() : List.of(); + + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + properties, outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. @@ -118,7 +129,8 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, Map buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, - int remainingDepth) { + int remainingDepth, + boolean includeProperties) { Map> sourcesByRelationName = new java.util.HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { @@ -136,7 +148,7 @@ private List buildRelationsAsTargetFromMap(String targetIde .map(e -> new EntityGraphRelation( e.getKey(), e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth)) + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, includeProperties)) .toList() )) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index c3b229e..23e72bd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -164,4 +164,6 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 872b846..ee8f58a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -5,6 +5,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_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 org.springframework.http.HttpStatus.OK; @@ -53,6 +54,7 @@ public class EntityGraphController { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @param includeData when true, each node includes a data object with entity property values /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -70,10 +72,12 @@ public EntityGraphFlatDtoOut getEntityGraph( @PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth) { + @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) + @RequestParam(defaultValue = "false") boolean includeData) { EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth); + templateIdentifier, entityIdentifier, depth, includeData); return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index 569941e..0ae498b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -1,10 +1,15 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -14,6 +19,9 @@ /// /// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect /// entities and their relationships as separate, non-nested collections. +/// +/// The optional `data` field is populated only when `include_data=true` is requested, +/// containing property name-to-value pairs for the entity. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeFlatDtoOut( @@ -27,5 +35,9 @@ public record EntityGraphNodeFlatDtoOut( String templateIdentifier, @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) - String identifier + String identifier, + + @JsonInclude(Include.NON_EMPTY) + @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) + Map data ) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index d8874c2..c7ca7a4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -4,9 +4,11 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.SequencedSet; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; @@ -38,8 +40,7 @@ private EntityGraphFlatDtoOutMapper() { /// /// @param root the root [EntityGraphNode] returned by the domain service /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { - if (root == null) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -74,7 +75,8 @@ private static void traverse( } nodes.add(new EntityGraphNodeFlatDtoOut( - nodeId, node.name(), node.templateIdentifier(), node.identifier())); + nodeId, node.name(), node.templateIdentifier(), node.identifier(), + toDataMap(node))); // Traverse outbound relations: emit edge from currentNode → target for (EntityGraphRelation relation : node.relations()) { @@ -120,4 +122,12 @@ private static void addEdge( private static String nodeId(String templateIdentifier, String identifier) { return templateIdentifier + ":" + identifier; } + + /// Converts a node's property list to a name→value map for the `data` field. + /// Returns an empty map when there are no properties; the DTO's @JsonInclude(NON_EMPTY) + /// annotation ensures an empty map is omitted from the JSON output. + private static Map toDataMap(EntityGraphNode node) { + return node.properties().stream() + .collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 5e38977..0d0efbf 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -75,7 +75,7 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH)) + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH, false)) .isInstanceOf(EntityNotFoundException.class); verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); @@ -97,7 +97,7 @@ void shouldReturnLeafNodeWhenNoRelations() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -125,7 +125,7 @@ void shouldResolveOutboundRelations() { key(DB_TEMPLATE, "postgres"), db )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(1); assertThat(result.relations().getFirst().name()).isEqualTo("uses"); @@ -145,7 +145,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) .thenReturn(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(1); // Fallback node uses identifier as both id and name when entity is not in map @@ -172,7 +172,7 @@ void shouldResolveInboundRelations() { key(DB_TEMPLATE, "postgres"), db )); - EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false); // postgres is targeted by api via "uses" assertThat(result.relationsAsTarget()).hasSize(1); @@ -195,7 +195,7 @@ void shouldClampDepthBelowOne() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); } @@ -210,7 +210,7 @@ void shouldClampDepthAboveTen() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99); + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); } @@ -240,7 +240,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { key(INFRA_TEMPLATE, "server-1"), server )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); // postgres node is included but its child relations are empty (remaining depth = 0) var dbNode = result.relations().getFirst().targets().getFirst(); @@ -272,7 +272,7 @@ void shouldResolveMultipleNamedRelations() { key(CACHE_TEMPLATE, "redis"), cache )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(2); var relationNames = result.relations().stream().map(r -> r.name()).toList(); From dd5536af7e8207a636d3295cd216a28158157ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 11:34:08 +0200 Subject: [PATCH 17/27] feat(entity-graph): add a entity graph service and endpoint add include flag for getting properties or not in graph repository call --- .../port/EntityGraphRepositoryPort.java | 5 ++++- .../entity_graph/EntityGraphService.java | 9 ++++----- .../PostgresEntityGraphAdapter.java | 12 +++++++---- .../entity_graph/EntityGraphServiceTest.java | 20 +++++++++---------- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index e1ae73f..c66ba2d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -29,9 +29,12 @@ public interface EntityGraphRepositoryPort { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity within its template /// @param depth the maximum traversal depth (1-10) + /// @param includeProperties when true, entity properties are loaded along with relations; + /// when false, only relations are fetched for a leaner query /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found Map findEntityGraph( String templateIdentifier, String entityIdentifier, - int depth); + int depth, + boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index e32f1ae..66ee2f9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -64,9 +64,10 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // Optimized batch fetch: load all entities in the graph keyed by composite key + // Optimized batch fetch: load all entities in the graph keyed by composite key. + // Properties are fetched only when explicitly requested to avoid unnecessary I/O. Map entityMap = entityGraphRepositoryPort - .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth); + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); @@ -111,10 +112,8 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Include properties only when explicitly requested to keep responses lean List properties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); - } + properties, outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. /// Falls back to a synthetic key if no match is found (entity not in graph). diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index 8527027..deeb2b6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -37,7 +37,8 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { public Map findEntityGraph( String templateIdentifier, String entityIdentifier, - int depth) { + int depth, + boolean includeProperties) { // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( templateIdentifier, entityIdentifier, depth); @@ -52,11 +53,14 @@ public Map findEntityGraph( .distinct() .toList(); - // Step 3: batch-load entities with relations, then properties in separate queries - // to avoid Hibernate's MultipleBagFetchException + // Step 3: batch-load entities with relations, then optionally properties in a separate + // query. Properties are skipped when not requested to avoid the extra round-trip and + // keep payloads lean. The two-query split also avoids Hibernate's MultipleBagFetchException. List jpaEntities = jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); - jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + if (includeProperties) { + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + } // Step 4: map to domain and key by composite key for O(1) lookup return jpaEntities.stream() diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0d0efbf..7dbaf9d 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -94,7 +94,7 @@ void shouldReturnLeafNodeWhenNoRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); @@ -119,7 +119,7 @@ void shouldResolveOutboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db @@ -142,7 +142,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), api)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); @@ -166,7 +166,7 @@ void shouldResolveInboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) .thenReturn(Optional.of(db)); - when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db @@ -192,12 +192,12 @@ void shouldClampDepthBelowOne() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } @Test @@ -207,12 +207,12 @@ void shouldClampDepthAboveTen() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); } } @@ -233,7 +233,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db, @@ -265,7 +265,7 @@ void shouldResolveMultipleNamedRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db, From 3b8c995643b1a3ce02b96d05b99a3c6cb8df8f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 17:53:46 +0200 Subject: [PATCH 18/27] feat(entity-graph): add a entity graph service and endpoint --- .../idp_core/domain/model/entity/Entity.java | 6 + .../domain/model/entity/EntityGraphNode.java | 0 .../model/entity/EntityGraphRelation.java | 0 .../model/entity_template/EntityTemplate.java | 6 + .../port/EntityGraphRepositoryPort.java | 6 + .../entity_graph/EntityGraphService.java | 78 +++-- .../api/configuration/CorsProperties.java | 6 +- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 20 +- .../dto/out/entity/EntityGraphNodeDtoOut.java | 0 .../out/entity/EntityGraphNodeFlatDtoOut.java | 8 +- .../out/entity/EntityGraphRelationDtoOut.java | 0 .../entity/EntityGraphDtoOutMapper.java | 0 .../entity/EntityGraphFlatDtoOutMapper.java | 53 ++- .../PostgresEntityGraphAdapter.java | 8 +- .../repository/JpaEntityRepository.java | 52 +++ .../entity_graph/EntityGraphServiceTest.java | 312 ++++++++++++------ .../api/controller/EntityControllerTest.java | 8 +- .../controller/EntityGraphControllerTest.java | 155 +++++++++ .../test/R__2_Insert_entities_test_data.sql | 50 ++- 20 files changed, 605 insertions(+), 164 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index ab10abe..2292ecd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -36,4 +36,10 @@ public record Entity( List relations ) { + /// Compact constructor: defensively copies mutable lists to prevent external mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public Entity { + properties = properties == null ? List.of() : List.copyOf(properties); + relations = relations == null ? List.of() : List.copyOf(relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 9a0fb0b..2d694f1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -43,4 +43,10 @@ public record EntityTemplate( List relationsDefinitions ) { + /// Compact constructor: defensively copies mutable lists to prevent external mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions == null ? List.of() : List.copyOf(propertiesDefinitions); + relationsDefinitions = relationsDefinitions == null ? List.of() : List.copyOf(relationsDefinitions); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index c66ba2d..82996a2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -31,7 +31,13 @@ public interface EntityGraphRepositoryPort { /// @param depth the maximum traversal depth (1-10) /// @param includeProperties when true, entity properties are loaded along with relations; /// when false, only relations are fetched for a leaner query + /// @param relationNames when non-empty, only edges whose relation name is in this set are + /// traversed; when empty, all relation types are followed /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found + /// Relation name filtering is intentionally NOT pushed into this port. + /// The CTE always traverses all relation types so that nodes reachable via + /// any path are loaded. Edge filtering is applied in the service layer so + /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. Map findEntityGraph( String templateIdentifier, String entityIdentifier, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 66ee2f9..4d989ee 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -1,7 +1,11 @@ package com.decathlon.idp_core.domain.service.entity_graph; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,7 +36,11 @@ /// **Design decisions:** /// - Uses depth-limited traversal to prevent unbounded recursion /// - Optimized with recursive CTE and batch loading to minimize database queries -/// - Does not detect cycles — relies on depth limit to terminate +/// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, +/// inbound relation scanning would re-expand already-visited nodes at every depth +/// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). +/// - The service always returns the full unfiltered graph tree. Relation name filtering +/// is a presentation concern applied by the mapper layer. @Service @RequiredArgsConstructor public class EntityGraphService { @@ -44,76 +52,81 @@ public class EntityGraphService { /// Builds the relationship graph for an entity starting from its composite key. /// - /// **Optimization:** Uses a recursive CTE to fetch all entities in the graph in 2 queries - /// (1 for composite key pairs, 1 for batch loading), regardless of depth. - /// /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) - /// @param includeProperties when true, each graph node carries the entity's full property list; - /// when false, properties are omitted to reduce response size - /// @return the root graph node with resolved relations + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's full property list + /// @return the root graph node with all resolved relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - // Verify root entity exists before fetching the graph Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // Optimized batch fetch: load all entities in the graph keyed by composite key. - // Properties are fetched only when explicitly requested to avoid unnecessary I/O. Map entityMap = entityGraphRepositoryPort .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); - // Build the graph from pre-loaded entities (no more database calls) - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties); + // One shared visited set per request — each node is fully expanded at most once, + // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. + Set visitedNodeIds = new HashSet<>(); + + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); } /// Builds a graph node from a pre-loaded entity map (no database calls). - /// Recursively resolves both outbound and inbound relations from the cached entities. + /// + /// [visitedNodeIds] tracks nodes that have already been fully built in this traversal. + /// When a node is encountered again (cycle or shared reference), a stub leaf is returned + /// immediately to cut the recursion — preventing the exponential blowup that arises from + /// inbound scanning re-expanding the same nodes at every depth level. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, int remainingDepth, - boolean includeProperties) { + boolean includeProperties, + Set visitedNodeIds) { Entity entity = entityMap.get(key); if (entity == null) { return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of(), List.of()); } + // Guard: return a stub leaf if this node was already fully built in another branch. + // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); + if (!visitedNodeIds.add(nodeId)) { + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + List.of(), List.of(), List.of()); + } + if (remainingDepth <= 0) { return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of(), List.of()); } - // Resolve outbound relations from pre-loaded entities List outboundRelations = entity.relations().stream() .map(relation -> new EntityGraphRelation( relation.name(), relation.targetEntityIdentifiers().stream() - .map(targetId -> { - // Relations only store identifier; look up by identifier across all entries - EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); - return buildGraphNode(targetKey, entityMap, remainingDepth - 1, includeProperties); - }) + .map(targetId -> buildGraphNode( + findKeyByIdentifier(targetId, entityMap), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) .toList() )) .toList(); - // Resolve inbound relations from pre-loaded entities List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1, includeProperties); + entity.identifier(), entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); - // Include properties only when explicitly requested to keep responses lean List properties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); } + properties, outboundRelations, inboundRelations); + } /// Looks up a composite key from the map by identifier alone. /// Falls back to a synthetic key if no match is found (entity not in graph). @@ -125,19 +138,21 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, Map buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, int remainingDepth, - boolean includeProperties) { - Map> sourcesByRelationName = new java.util.HashMap<>(); + boolean includeProperties, + Set visitedNodeIds) { + Map> sourcesByRelationName = new HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { Entity sourceEntity = entry.getValue(); for (Relation relation : sourceEntity.relations()) { if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { sourcesByRelationName - .computeIfAbsent(relation.name(), k -> new java.util.ArrayList<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()) .add(entry.getKey()); } } @@ -147,7 +162,8 @@ private List buildRelationsAsTargetFromMap(String targetIde .map(e -> new EntityGraphRelation( e.getKey(), e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, includeProperties)) + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, visitedNodeIds)) .toList() )) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 19124e5..ddd780a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -9,9 +9,9 @@ public record CorsProperties( List allowedOrigins ) { + /// Compact constructor: normalises null to empty and defensively copies to prevent + /// external mutation of the configuration list (EI_EXPOSE_REP2 / EI_EXPOSE_REP). public CorsProperties { - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 23e72bd..19a06f4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -166,4 +166,5 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index ee8f58a..c71b88e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -6,10 +6,14 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_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 org.springframework.http.HttpStatus.OK; +import java.util.List; +import java.util.Set; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -52,9 +56,10 @@ public class EntityGraphController { /// Cytoscape, and similar frontend graph visualization libraries. /// /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @param includeData when true, each node includes a data object with entity property values + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @param includeData when true, each node includes a data object with entity property values + /// @param relations when provided, only relations with matching names are included /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -74,11 +79,16 @@ public EntityGraphFlatDtoOut getEntityGraph( @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(defaultValue = "false") boolean includeData) { + @RequestParam(defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) + @RequestParam(required = false) List relations) { + + // Convert the nullable list to a Set for O(1) lookup; empty set means no filter + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph( templateIdentifier, entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index 0ae498b..c1fa208 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -40,4 +40,10 @@ public record EntityGraphNodeFlatDtoOut( @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data -) {} +) { + /// Compact constructor: defensively copies the data map to prevent external mutation + /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index c7ca7a4..3bf9932 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -38,9 +38,14 @@ private EntityGraphFlatDtoOutMapper() { /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// - /// @param root the root [EntityGraphNode] returned by the domain service + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, + /// and nodes not referenced by any remaining edge are pruned from the + /// result (except the root, which is always included); + /// an empty set means no filter — all edge types and nodes are emitted /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter) { + if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -54,9 +59,30 @@ public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if Set emittedEdgeSignatures = new HashSet<>(); var edgeCounter = new AtomicInteger(0); - traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + + // When a relation filter is active, prune nodes that are not connected to any + // remaining edge. The root is always kept. Without this step, nodes reachable via + // non-filtered edges (e.g. C via "depends-on" when filtering "monitors") would + // appear in the node list despite having no visible edges. + List finalNodes; + if (relationFilter.isEmpty()) { + finalNodes = List.copyOf(nodes); + } else { + // Collect all node IDs referenced by the filtered edges only. + // The root receives no special treatment: if it has no matching edges + // it is pruned just like any other disconnected node. + Set referencedNodeIds = new HashSet<>(); + for (var edge : edges) { + referencedNodeIds.add(edge.source()); + referencedNodeIds.add(edge.target()); + } + finalNodes = nodes.stream() + .filter(n -> referencedNodeIds.contains(n.id())) + .toList(); + } - return new EntityGraphFlatDtoOut(List.copyOf(nodes), List.copyOf(edges)); + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(edges)); } private static void traverse( @@ -65,7 +91,8 @@ private static void traverse( List edges, Set visitedNodeIds, Set emittedEdgeSignatures, - AtomicInteger edgeCounter) { + AtomicInteger edgeCounter, + Set relationFilter) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); @@ -78,12 +105,16 @@ private static void traverse( nodeId, node.name(), node.templateIdentifier(), node.identifier(), toDataMap(node))); - // Traverse outbound relations: emit edge from currentNode → target + // Traverse outbound relations: emit edge from currentNode → target only when the + // relation type matches the filter (or no filter is active). Nodes are always + // traversed so that deeper nodes remain reachable regardless of edge visibility. for (EntityGraphRelation relation : node.relations()) { for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); - addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); - traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + } + traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); } } @@ -93,8 +124,10 @@ private static void traverse( for (EntityGraphRelation relation : node.relationsAsTarget()) { for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); - traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + } + traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); } } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index deeb2b6..d48828c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; @@ -34,12 +35,17 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { private final EntityPersistenceMapper mapper; @Override + @Transactional(readOnly = true) public Map findEntityGraph( String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties) { - // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE + // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE. + // The CTE always traverses ALL relation types to discover all reachable nodes. + // Relation name filtering is applied at the service level when building edges, + // so nodes reachable via any path are included even if the filter only matches + // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( templateIdentifier, entityIdentifier, depth); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index fb94167..8be0fa7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -116,4 +116,56 @@ List findEntityGraphIdentifiers( @Param("templateIdentifier") String templateIdentifier, @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the given relation names. + /// When the list is empty, all relation names are followed (no filter). + /// The filter is applied inside both the outbound and inbound recursive CTE steps so that only + /// entities reachable through the specified relations are returned, keeping the result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, + @Param("depth") int depth, + @Param("relationNames") Collection relationNames); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 7dbaf9d..0f6b50f 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -2,8 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.List; @@ -24,6 +27,7 @@ import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; @@ -31,12 +35,6 @@ @DisplayName("EntityGraphService Tests") class EntityGraphServiceTest { - private static final String TEMPLATE = "web-service"; - private static final String DB_TEMPLATE = "database"; - private static final String CACHE_TEMPLATE = "cache"; - private static final String INFRA_TEMPLATE = "infrastructure"; - private static final int DEFAULT_DEPTH = 3; - @Mock private EntityRepositoryPort entityRepositoryPort; @@ -57,16 +55,26 @@ private Entity entityWithRelations(String templateIdentifier, String identifier, return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); } - private Relation relation(String name, String targetTemplateIdentifier, List targetIdentifiers) { - return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targetIdentifiers); + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); } private EntityCompositeKey key(String templateIdentifier, String identifier) { return new EntityCompositeKey(templateIdentifier, identifier); } + private static final String TEMPLATE = "web-service"; + + // --- Helper to stub both ports --- + + private void stubGraph(Map entityMap) { + when(entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } + + // ======================== @Nested - @DisplayName("getEntityGraph — root entity not found") + @DisplayName("Root Entity Not Found") class RootEntityNotFound { @Test @@ -75,29 +83,28 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH, false)) + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) .isInstanceOf(EntityNotFoundException.class); - verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); - verifyNoInteractions(entityGraphRepositoryPort); + verify(entityGraphRepositoryPort, never()) + .findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); } } + // ======================== @Nested - @DisplayName("getEntityGraph — single root, no relations") + @DisplayName("Single Root — No Relations") class SingleRootNoRelations { @Test - @DisplayName("Should return a leaf node when entity has no relations") + @DisplayName("Should return leaf node when entity has no relations") void shouldReturnLeafNodeWhenNoRelations() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -106,94 +113,88 @@ void shouldReturnLeafNodeWhenNoRelations() { } } + // ======================== @Nested - @DisplayName("getEntityGraph — outbound relations") + @DisplayName("Outbound Relations") class OutboundRelations { @Test - @DisplayName("Should resolve outbound relations to graph nodes") + @DisplayName("Should resolve outbound relation targets at depth 1") void shouldResolveOutboundRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(1); - assertThat(result.relations().getFirst().name()).isEqualTo("uses"); - assertThat(result.relations().getFirst().targets()).hasSize(1); - assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("postgres"); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); } @Test - @DisplayName("Should create a fallback node when relation target is not in the graph map") + @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") void shouldReturnFallbackNodeWhenTargetNotInMap() { - // Simulates a target entity outside the loaded depth — still produces a placeholder node - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("unknown-db")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(1); - // Fallback node uses identifier as both id and name when entity is not in map - assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("unknown-db"); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); } } + // ======================== @Nested - @DisplayName("getEntityGraph — inbound relations") + @DisplayName("Inbound Relations (relationsAsTarget)") class InboundRelations { @Test - @DisplayName("Should resolve inbound relations for entities that are targeted by others") + @DisplayName("Should resolve inbound relations when another entity points to root") void shouldResolveInboundRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) - .thenReturn(Optional.of(db)); - when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db - )); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key(TEMPLATE, "consumer"), consumer)); - EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - // postgres is targeted by api via "uses" assertThat(result.relationsAsTarget()).hasSize(1); - assertThat(result.relationsAsTarget().getFirst().name()).isEqualTo("uses"); - assertThat(result.relationsAsTarget().getFirst().targets().getFirst().identifier()).isEqualTo("api"); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()).isEqualTo("consumer"); } } + // ======================== @Nested - @DisplayName("getEntityGraph — depth clamping") + @DisplayName("Depth Clamping") class DepthClamping { @Test @DisplayName("Should clamp depth below 1 to 1") void shouldClampDepthBelowOne() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); @@ -201,14 +202,12 @@ void shouldClampDepthBelowOne() { } @Test - @DisplayName("Should clamp depth above 10 to 10") + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") void shouldClampDepthAboveTen() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); @@ -216,67 +215,164 @@ void shouldClampDepthAboveTen() { } } + // ======================== @Nested - @DisplayName("getEntityGraph — depth limit stops recursion") + @DisplayName("Depth Limit — Leaf Nodes at Boundary") class DepthLimit { @Test - @DisplayName("Should return a leaf node for targets at the depth boundary") + @DisplayName("Should return target as leaf node when depth limit is reached") void shouldReturnLeafNodeAtDepthBoundary() { - // api --uses--> postgres --runs-on--> server-1 - // At depth=1: postgres node is resolved but its own relations are NOT expanded - var server = entity(INFRA_TEMPLATE, "server-1", "Server 1"); - var db = entityWithRelations(DB_TEMPLATE, "postgres", "Postgres DB", - List.of(relation("runs-on", INFRA_TEMPLATE, List.of("server-1")))); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", + List.of(relation("runs-on", "infra", "server-1"))); + Entity server = entity("infra", "server-1", "Server 1"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db, - key(INFRA_TEMPLATE, "server-1"), server - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key("infra", "server-1"), server)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - // postgres node is included but its child relations are empty (remaining depth = 0) - var dbNode = result.relations().getFirst().targets().getFirst(); - assertThat(dbNode.identifier()).isEqualTo("postgres"); - assertThat(dbNode.relations()).isEmpty(); + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further relations resolved + assertThat(postgresNode.relations()).isEmpty(); + assertThat(postgresNode.relationsAsTarget()).isEmpty(); } } + // ======================== @Nested - @DisplayName("getEntityGraph — multiple outbound relations") + @DisplayName("Multiple Named Relations") class MultipleRelations { @Test - @DisplayName("Should resolve multiple named relation types correctly") + @DisplayName("Should resolve multiple distinct relation types") void shouldResolveMultipleNamedRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var cache = entity(CACHE_TEMPLATE, "redis", "Redis Cache"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( - relation("uses-db", DB_TEMPLATE, List.of("postgres")), - relation("uses-cache", CACHE_TEMPLATE, List.of("redis")) - )); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", "database", "postgres"), + relation("depends-on", TEMPLATE, "auth"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity auth = entity(TEMPLATE, "auth", "Auth Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db, - key(CACHE_TEMPLATE, "redis"), cache - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(2); - var relationNames = result.relations().stream().map(r -> r.name()).toList(); - assertThat(relationNames).containsExactlyInAnyOrder("uses-db", "uses-cache"); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") + class FullGraphReturned { + + @Test + @DisplayName("Should return all edges regardless of relation type (no filtering in service)") + void shouldReturnAllEdgesWithoutFiltering() { + // A --(depends-on)--> B --(owns)--> C + // The service must return both edges — the mapper will filter them. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("owns", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b, + key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + + // Root A has one outbound "depends-on" edge → B + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + + // B (at depth 1) has one outbound "owns" edge → C + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + assertThat(nodeB.relations()).hasSize(1); + assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); + assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + void shouldNotExplodeAtMaxDepthWithSmallGraph() { + // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound from B. + // Without the visited-node guard this produces O(2^depth) calls at depth=10. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("uses", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b, + key(TEMPLATE, "c"), c)); + + // Must complete instantly — any OOM or StackOverflow here means the guard is missing. + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + + assertThat(result.identifier()).isEqualTo("a"); + assertThat(result.relations()).hasSize(1); + } + + @Test + @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") + void shouldReturnStubLeafForRevisitedNode() { + // A --(uses)--> B; B also points back to A (cycle: A→B→A) + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("uses", TEMPLATE, "a"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + + // A → B is resolved + assertThat(result.relations()).hasSize(1); + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + + // B → A is a revisit: A was already marked visited, so it returns a stub leaf + // with no further outbound or inbound relations (no infinite loop). + EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); + assertThat(stubA.identifier()).isEqualTo("a"); + assertThat(stubA.relations()).isEmpty(); + assertThat(stubA.relationsAsTarget()).isEmpty(); } } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index ccee22d..53f5b15 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -48,8 +48,8 @@ void getEntities_paginated_200() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -102,8 +102,8 @@ void getEntities_invalid_pagination_200() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java new file mode 100644 index 0000000..d2ef939 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -0,0 +1,155 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +/// Integration tests for the EntityGraphController REST API endpoint. +/// +/// Tests are based on the three-node chain seeded in R__2_Insert_entities_test_data.sql: +/// +/// graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +/// graph-svc-a --[monitors]--> graph-svc-b +/// +/// Key scenarios verified: +/// +/// - No filter: all nodes and edges are returned +/// - Filter "uses": full chain traversed (a→b→c), "monitors" edge excluded at every depth +/// - Filter "monitors": only a→b returned; c is unreachable via "monitors" edges +/// - 404 for unknown entity +/// - 401 without authentication +@DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") +public class EntityGraphControllerTest extends AbstractIntegrationTest { + + private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; + private static final String TEMPLATE = "web-service"; + private static final String ENTITY_A = "graph-svc-a"; + private static final String ENTITY_B = "graph-svc-b"; + private static final String ENTITY_C = "graph-svc-c"; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Without relation filter") + class NoFilter { + + @Test + @WithMockUser + @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") + void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes must be present + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(3))); + } + } + + @Nested + @DisplayName("With 'uses' relation filter") + class UsesFilter { + + @Test + @WithMockUser + @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") + void shouldTraverseFullChainWithUsesFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("relations", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are reachable via "uses" chain: a→b→c + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Only the two "uses" edges: a-[uses]->b and b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(2))) + .andExpect(jsonPath("$.edges[*].type", + containsInAnyOrder("uses", "uses"))); + } + + @Test + @WithMockUser + @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") + void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { + // This specifically verifies that the filter applies recursively: + // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "2") + .param("relations", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + .andExpect(jsonPath("$.edges", hasSize(2))); + } + } + + @Nested + @DisplayName("With 'monitors' relation filter") + class MonitorsFilter { + + @Test + @WithMockUser + @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") + void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { + // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, + // graph-svc-c must NOT appear in the result. + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("relations", "monitors") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // Only a and b — c is unreachable via "monitors" + .andExpect(jsonPath("$.nodes", hasSize(2))) + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B))) + // One edge only: a-[monitors]->b + .andExpect(jsonPath("$.edges", hasSize(1))) + .andExpect(jsonPath("$.edges[0].type").value("monitors")); + } + } + + @Nested + @DisplayName("Error cases") + class ErrorCases { + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void shouldReturn404ForUnknownEntity() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void shouldReturn401WithoutAuthentication() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 45e62ff..d80b4d5 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,5 +1,5 @@ -- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), @@ -16,3 +16,51 @@ VALUES ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +-- Entities (all use the 'web-service' template which exists in test data) +-- UUIDs use only valid hex characters (0-9, a-f) +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation From 545078b76aefe053a5b242805192c56ab208e271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 18:31:38 +0200 Subject: [PATCH 19/27] feat(entity-graph): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 682 ++++++++++++++++++----------------- 1 file changed, 361 insertions(+), 321 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index accb23c..590d771 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -6,140 +6,144 @@ info: servers: - url: http://localhost:8084 security: - - clientId: [] - - bearer: [] +- clientId: [] +- bearer: [] tags: - - name: Entities Management - description: Operations related to entity management - - name: Entities Templates Management - description: Operations related to entity template management +- name: Entity Graph + description: Entity relationship graph operations +- name: Entities Management + description: Operations related to entity management +- name: Entities Templates Management + description: Operations related to entity template management paths: - /api/v1/entity-templates/{identifier}: + "/api/v1/entity-templates/{identifier}": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get template by identifier description: Retrieve a specific template using its string identifier operationId: getTemplateByIdentifier parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '200': description: Template found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" put: tags: - - Entities Templates Management + - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique string identifier + description: Update the details of an existing template identified by its unique + string identifier operationId: updateTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + "$ref": "#/components/schemas/EntityTemplateUpdateDtoIn" required: true responses: '200': description: Template update successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" delete: tags: - - Entities Templates Management + - Entities Templates Management summary: Delete template by identifier description: Remove a template from the system using its unique identifier operationId: deleteTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '204': description: Template deleted successfully '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entity-templates: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entity-templates": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get paginated templates description: Retrieve a paginated list of templates with optional sorting operationId: getTemplatesPaginated parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - content: - '*/*': - schema: - type: integer - default: '20' - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + "*/*": + schema: + type: integer + default: '20' + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated templates retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/TemplatePageResponse' + "$ref": "#/components/schemas/TemplatePageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Templates Management + - Entities Templates Management summary: Create a new template description: Create a new template in the system with the provided information operationId: createTemplate @@ -147,193 +151,223 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + "$ref": "#/components/schemas/EntityTemplateCreateDtoIn" required: true responses: '201': description: Template created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '400': description: Invalid template data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entities by template identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - required: false - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - required: false - content: - '*/*': - schema: - type: integer - default: '20' - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + "*/*": + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityPageResponse' + "$ref": "#/components/schemas/EntityPageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Management + - Entities Management summary: Create a new entity description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityDtoIn' + "$ref": "#/components/schemas/EntityDtoIn" required: true responses: '201': description: Entity created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '400': description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights - '409': - description: Entity already exists in this template - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: Entity already exists in this template + content: + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '500': description: Unexpected server-side failure content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": + get: + tags: + - Entity Graph + summary: Get entity relationship graph as flat nodes and edges + 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. + operationId: getEntityGraph + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: depth + in: query + description: Maximum traversal depth for relationship resolution. Clamped + between 1 and 10. + required: false + schema: + type: integer + format: int32 + default: 1 + - name: includeData + in: query + description: When true, each graph node includes a data object containing + the entity's property values. Defaults to false. + required: false + schema: + type: boolean + default: false + - name: relations + in: query + description: When provided, only relations whose name matches one of the listed + values are traversed and included. Omit to include all relations. + required: false + schema: + type: array + items: + type: string + responses: + '200': + description: Flat entity graph successfully retrieved + content: + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + "$ref": "#/components/schemas/EntityGraphFlatDtoOut" + '404': + description: Entity not found with the provided identifier + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its template identifier + description: Retrieve a specific entity using its string identifier and its + template identifier operationId: getEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: entityIdentifier - in: path - required: true - schema: - type: string + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string responses: '200': description: Entity found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '404': description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" components: schemas: - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Entity Template name - example: Service - maxLength: 255 - minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" - description: - type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' - relations_definitions: - type: array - description: List of relation definitions for this template - items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' - required: - - identifier - - name EntityTemplateUpdateDtoIn: type: object description: Input DTO for updating an entity template @@ -353,14 +387,14 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' + "$ref": "#/components/schemas/RelationDefinitionDtoIn" required: - - name + - name PropertyDefinitionDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -379,9 +413,9 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean @@ -389,12 +423,12 @@ components: description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' + "$ref": "#/components/schemas/PropertyRulesDtoIn" description: Property validation rules required: - - description - - name - - type + - description + - name + - type PropertyRulesDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -403,21 +437,21 @@ components: type: string description: Property format validation enum: - - URL - - EMAIL + - URL + - EMAIL example: EMAIL enum_values: type: array description: Enumeration values for enum properties example: - - ACTIVE - - INACTIVE + - ACTIVE + - INACTIVE items: type: string regex: type: string description: Regular expression pattern for validation - example: ^[a-zA-Z0-9]+$ + example: "^[a-zA-Z0-9]+$" max_length: type: integer format: int32 @@ -463,8 +497,8 @@ components: description: Whether this relation can have multiple targets example: true required: - - name - - target_template_identifier + - name + - target_template_identifier EntityTemplateDtoOut: type: object description: Output for entity template @@ -485,12 +519,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoOut' + "$ref": "#/components/schemas/PropertyDefinitionDtoOut" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoOut' + "$ref": "#/components/schemas/RelationDefinitionDtoOut" PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -507,46 +541,41 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoOut' + "$ref": "#/components/schemas/PropertyRulesDtoOut" description: Property validation rules example: Property validation rules PropertyRulesDtoOut: type: object description: Output DTO for property validation rules properties: - id: - type: string - format: uuid - description: Unique identifier of the property rules - example: 123e4567-e89b-12d3-a456-426614174000 format: type: string description: Format of the property enum: - - URL - - EMAIL + - URL + - EMAIL example: STRING enum_values: type: array description: Allowed enum values for the property example: - - VALUE1 - - VALUE2 + - VALUE1 + - VALUE2 items: type: string regex: type: string description: Regular expression for property validation - example: ^[A-Za-z0-9]+$ + example: "^[A-Za-z0-9]+$" max_length: type: integer format: int32 @@ -592,78 +621,41 @@ components: properties: error: type: string - enum: - - 100 CONTINUE - - 101 SWITCHING_PROTOCOLS - - 102 PROCESSING - - 103 EARLY_HINTS - - 103 CHECKPOINT - - 200 OK - - 201 CREATED - - 202 ACCEPTED - - 203 NON_AUTHORITATIVE_INFORMATION - - 204 NO_CONTENT - - 205 RESET_CONTENT - - 206 PARTIAL_CONTENT - - 207 MULTI_STATUS - - 208 ALREADY_REPORTED - - 226 IM_USED - - 300 MULTIPLE_CHOICES - - 301 MOVED_PERMANENTLY - - 302 FOUND - - 302 MOVED_TEMPORARILY - - 303 SEE_OTHER - - 304 NOT_MODIFIED - - 305 USE_PROXY - - 307 TEMPORARY_REDIRECT - - 308 PERMANENT_REDIRECT - - 400 BAD_REQUEST - - 401 UNAUTHORIZED - - 402 PAYMENT_REQUIRED - - 403 FORBIDDEN - - 404 NOT_FOUND - - 405 METHOD_NOT_ALLOWED - - 406 NOT_ACCEPTABLE - - 407 PROXY_AUTHENTICATION_REQUIRED - - 408 REQUEST_TIMEOUT - - 409 CONFLICT - - 410 GONE - - 411 LENGTH_REQUIRED - - 412 PRECONDITION_FAILED - - 413 PAYLOAD_TOO_LARGE - - 413 REQUEST_ENTITY_TOO_LARGE - - 414 URI_TOO_LONG - - 414 REQUEST_URI_TOO_LONG - - 415 UNSUPPORTED_MEDIA_TYPE - - 416 REQUESTED_RANGE_NOT_SATISFIABLE - - 417 EXPECTATION_FAILED - - 418 I_AM_A_TEAPOT - - 419 INSUFFICIENT_SPACE_ON_RESOURCE - - 420 METHOD_FAILURE - - 421 DESTINATION_LOCKED - - 422 UNPROCESSABLE_ENTITY - - 423 LOCKED - - 424 FAILED_DEPENDENCY - - 425 TOO_EARLY - - 426 UPGRADE_REQUIRED - - 428 PRECONDITION_REQUIRED - - 429 TOO_MANY_REQUESTS - - 431 REQUEST_HEADER_FIELDS_TOO_LARGE - - 451 UNAVAILABLE_FOR_LEGAL_REASONS - - 500 INTERNAL_SERVER_ERROR - - 501 NOT_IMPLEMENTED - - 502 BAD_GATEWAY - - 503 SERVICE_UNAVAILABLE - - 504 GATEWAY_TIMEOUT - - 505 HTTP_VERSION_NOT_SUPPORTED - - 506 VARIANT_ALSO_NEGOTIATES - - 507 INSUFFICIENT_STORAGE - - 508 LOOP_DETECTED - - 509 BANDWIDTH_LIMIT_EXCEEDED - - 510 NOT_EXTENDED - - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityTemplateCreateDtoIn: + type: object + description: Input DTO for creating an entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: "^[a-zA-Z0-9 _-]+$" + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + relations_definitions: + type: array + description: List of relation definitions for this template + items: + "$ref": "#/components/schemas/RelationDefinitionDtoIn" + required: + - identifier + - name EntityDtoIn: type: object description: Input DTO for creating or updating an entity @@ -689,10 +681,10 @@ components: type: array description: List of relations for this entity items: - $ref: '#/components/schemas/RelationDtoIn' + "$ref": "#/components/schemas/RelationDtoIn" required: - - identifier - - name + - identifier + - name RelationDtoIn: type: object description: Input DTO for an entity relation instance @@ -706,13 +698,13 @@ components: type: array description: List of target entity identifiers for this relation example: - - web-api-1 - - web-api-2 + - web-api-1 + - web-api-2 items: type: string required: - - name - - target_entity_identifiers + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -730,13 +722,13 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" relations_as_target: type: object additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" EntitySummaryDto: type: object properties: @@ -747,13 +739,6 @@ components: PageableObject: type: object properties: - offset: - type: integer - format: int64 - sort: - $ref: '#/components/schemas/SortObject' - unpaged: - type: boolean paged: type: boolean pageNumber: @@ -762,15 +747,22 @@ components: pageSize: type: integer format: int32 + sort: + "$ref": "#/components/schemas/SortObject" + unpaged: + type: boolean + offset: + type: integer + format: int64 SortObject: type: object properties: - empty: - type: boolean sorted: type: boolean unsorted: type: boolean + empty: + type: boolean TemplatePageResponse: type: object description: Paginated response containing Template objects @@ -778,9 +770,9 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -789,17 +781,17 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: @@ -811,9 +803,9 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -822,21 +814,69 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: type: boolean + EntityGraphEdgeDtoOut: + type: object + properties: + id: + type: string + description: Unique edge identifier + source: + type: string + description: Node id of the source entity + target: + type: string + description: Node id of the target entity + type: + type: string + description: Relation name as defined in the entity template + EntityGraphFlatDtoOut: + type: object + properties: + nodes: + type: array + description: All entity nodes in the graph + items: + "$ref": "#/components/schemas/EntityGraphNodeFlatDtoOut" + edges: + type: array + description: All directed relation edges in the graph + items: + "$ref": "#/components/schemas/EntityGraphEdgeDtoOut" + EntityGraphNodeFlatDtoOut: + type: object + properties: + id: + type: string + description: Unique node identifier composed of templateIdentifier:identifier + label: + type: string + description: Human-readable entity name + template_identifier: + type: string + description: Template identifier this entity belongs to + identifier: + type: string + description: Business identifier of the entity within its template + data: + type: object + additionalProperties: {} + description: Entity property values keyed by property name; present only + when include_data=true is requested securitySchemes: clientId: type: oauth2 From b89ec6d43ecc3e2c72e25ea26443b038bc0046f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 22 May 2026 16:44:01 +0200 Subject: [PATCH 20/27] feat(core): add a entity graph service and endpoint --- .../api/configuration/CorsProperties.java | 13 +-- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 13 ++- .../entity/EntityGraphFlatDtoOutMapper.java | 106 +++++++++-------- .../controller/EntityGraphControllerTest.java | 68 +++++++++++ .../db/test/R__1_Insert_test_data.sql | 109 +++++++++++++++++- .../test/R__2_Insert_entities_test_data.sql | 66 ----------- 7 files changed, 251 insertions(+), 125 deletions(-) delete mode 100644 src/test/resources/db/test/R__2_Insert_entities_test_data.sql diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 841b7a9..d71c328 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -10,14 +10,11 @@ public record CorsProperties( List allowedOrigins, List allowedOriginPatterns ) { - /// Compact constructor: normalises null to empty and defensively copies to prevent - /// external mutation of the configuration list (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + /// Compact constructor: normalises null to empty and defensively copies every list + /// to prevent external mutation of the internal state (EI_EXPOSE_REP / EI_EXPOSE_REP2). + /// List.copyOf() also rejects null elements, enforcing a clean configuration contract. public CorsProperties { - if (allowedOriginPatterns == null) { - allowedOriginPatterns = List.of(); - } - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + allowedOriginPatterns = allowedOriginPatterns == null ? List.of() : List.copyOf(allowedOriginPatterns); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 19a06f4..feb6d60 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -167,4 +167,5 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index c71b88e..7ea3de4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -6,6 +6,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PROPERTIES_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_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; @@ -60,6 +61,7 @@ public class EntityGraphController { /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) /// @param includeData when true, each node includes a data object with entity property values /// @param relations when provided, only relations with matching names are included + /// @param properties when provided, each node's data object is restricted to the listed property names /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -79,16 +81,19 @@ public EntityGraphFlatDtoOut getEntityGraph( @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(defaultValue = "false") boolean includeData, + @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, @Parameter(description = PARAM_RELATIONS_DESCRIPTION) - @RequestParam(required = false) List relations) { + @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) + @RequestParam(required = false) List properties) { - // Convert the nullable list to a Set for O(1) lookup; empty set means no filter + // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph( templateIdentifier, entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index 3bf9932..8b90f8b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -36,74 +36,80 @@ private EntityGraphFlatDtoOutMapper() { // Utility class — not instantiable } + /// Groups mutable traversal accumulators to stay within the method-parameter limit + /// and keep the traversal signature readable. + private record TraversalState( + SequencedSet nodes, + List edges, + Set visitedNodeIds, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter) { + } + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// - /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, - /// and nodes not referenced by any remaining edge are pruned from the - /// result (except the root, which is always included); - /// an empty set means no filter — all edge types and nodes are emitted + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, + /// and nodes not referenced by any remaining edge are pruned; + /// an empty set means no filter — all edge types and nodes are emitted + /// @param propertyFilter when non-empty, only properties whose name is in this set appear + /// in each node's `data` field; + /// an empty set means no filter — all properties are included /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, + Set propertyFilter) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } - // Use a SequencedSet to deduplicate nodes while preserving insertion order - SequencedSet nodes = new LinkedHashSet<>(); - List edges = new ArrayList<>(); - // Tracks visited node IDs to prevent infinite loops in cyclic graphs - Set visitedNodeIds = new HashSet<>(); - // Tracks emitted edge signatures (source|target|label) to avoid duplicate edges - // when the same relation is encountered from both sides during traversal - Set emittedEdgeSignatures = new HashSet<>(); - var edgeCounter = new AtomicInteger(0); + var state = new TraversalState( + new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated + new ArrayList<>(), // edges + new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs + new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges + new AtomicInteger(0)); // edgeCounter - traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(root, state, relationFilter, propertyFilter); // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. The root is always kept. Without this step, nodes reachable via - // non-filtered edges (e.g. C via "depends-on" when filtering "monitors") would + // remaining edge. Without this step, nodes reachable via non-filtered edges would // appear in the node list despite having no visible edges. List finalNodes; if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(nodes); + finalNodes = List.copyOf(state.nodes()); } else { // Collect all node IDs referenced by the filtered edges only. // The root receives no special treatment: if it has no matching edges // it is pruned just like any other disconnected node. Set referencedNodeIds = new HashSet<>(); - for (var edge : edges) { + for (var edge : state.edges()) { referencedNodeIds.add(edge.source()); referencedNodeIds.add(edge.target()); } - finalNodes = nodes.stream() + finalNodes = state.nodes().stream() .filter(n -> referencedNodeIds.contains(n.id())) .toList(); } - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(edges)); + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); } private static void traverse( EntityGraphNode node, - SequencedSet nodes, - List edges, - Set visitedNodeIds, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter, - Set relationFilter) { + TraversalState state, + Set relationFilter, + Set propertyFilter) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); // Skip this node if already visited to prevent infinite loops in cyclic graphs - if (!visitedNodeIds.add(nodeId)) { + if (!state.visitedNodeIds().add(nodeId)) { return; } - nodes.add(new EntityGraphNodeFlatDtoOut( + state.nodes().add(new EntityGraphNodeFlatDtoOut( nodeId, node.name(), node.templateIdentifier(), node.identifier(), - toDataMap(node))); + toDataMap(node, propertyFilter))); // Traverse outbound relations: emit edge from currentNode → target only when the // relation type matches the filter (or no filter is active). Nodes are always @@ -112,9 +118,9 @@ private static void traverse( for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + addEdge(state, nodeId, targetId, relation.name()); } - traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(target, state, relationFilter, propertyFilter); } } @@ -125,9 +131,9 @@ private static void traverse( for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + addEdge(state, sourceId, nodeId, relation.name()); } - traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(source, state, relationFilter, propertyFilter); } } } @@ -136,17 +142,15 @@ private static void traverse( /// that arise when the same relation is encountered from both the source and the target /// during depth-first traversal. private static void addEdge( - List edges, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter, + TraversalState state, String sourceId, String targetId, String label) { var signature = sourceId + "|" + targetId + "|" + label; - if (emittedEdgeSignatures.add(signature)) { - edges.add(new EntityGraphEdgeDtoOut( - "e" + edgeCounter.incrementAndGet(), sourceId, targetId, label)); + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut( + "e" + state.edgeCounter().incrementAndGet(), sourceId, targetId, label)); } } @@ -157,10 +161,20 @@ private static String nodeId(String templateIdentifier, String identifier) { } /// Converts a node's property list to a name→value map for the `data` field. - /// Returns an empty map when there are no properties; the DTO's @JsonInclude(NON_EMPTY) - /// annotation ensures an empty map is omitted from the JSON output. - private static Map toDataMap(EntityGraphNode node) { - return node.properties().stream() - .collect(Collectors.toMap(p -> p.name(), p -> p.value())); + /// + /// When [propertyFilter] is non-empty, only entries whose name is contained in the + /// filter are included. Returns an empty map when there are no matching properties; + /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from + /// the JSON output. + /// + /// @param node the graph node whose properties are converted + /// @param propertyFilter when non-empty, restricts which properties appear in the map; + /// an empty set means all properties are included + private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { + var stream = node.properties().stream(); + if (!propertyFilter.isEmpty()) { + stream = stream.filter(p -> propertyFilter.contains(p.name())); + } + return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index d2ef939..ca30cf2 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -152,4 +152,72 @@ void shouldReturn401WithoutAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } } + + @Nested + @DisplayName("With 'properties' filter (include_data=true)") + class PropertyFilter { + + @Test + @WithMockUser + @DisplayName("Should include only requested property in each node's data when one property is requested") + void shouldIncludeOnlyRequestedProperty() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "tier") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are still returned + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Each node's data must contain "tier" … + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + // … but must NOT contain "version" + .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include multiple requested properties in each node's data") + void shouldIncludeMultipleRequestedProperties() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "tier") + .param("properties", "version") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return empty data when requested property does not exist on entity") + void shouldReturnEmptyDataForUnknownProperty() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "non-existent-prop") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) + .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include all properties when no property filter is supplied") + void shouldIncludeAllPropertiesWithoutFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index bab7cb5..3225589 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -1,6 +1,13 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates --- Clear existing data (for repeatable migrations) +-- Clear existing data (for repeatable migrations). +-- Deletion order respects FK constraints: child tables first, then parents. +DELETE FROM entity_properties; +DELETE FROM entity_relations; +DELETE FROM relation_target_entities; +DELETE FROM relation; +DELETE FROM entity; +DELETE FROM property; DELETE FROM entity_template_relations_definitions; DELETE FROM entity_template_properties_definitions; DELETE FROM entity_template; @@ -278,3 +285,103 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis + +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), + ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), + ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), + ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), + ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), + ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), + ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), + ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), + ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), + ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) +VALUES + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql deleted file mode 100644 index d80b4d5..0000000 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Insert sample entities into idp_core.entity -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), - ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), - ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), - ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), - ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), - ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), - ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), - ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - --- Entities (all use the 'web-service' template which exists in test data) --- UUIDs use only valid hex characters (0-9, a-f) -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation From 63072ef211039e0bc8989e8e4682575d0ba0c004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 28 May 2026 16:34:54 +0200 Subject: [PATCH 21/27] feat(core): add a entity graph service and endpoint --- .../domain/service/entity_graph/EntityGraphService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 4d989ee..af23ff7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -98,15 +98,20 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Guard: return a stub leaf if this node was already fully built in another branch. // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + // Properties are still included so data is not silently dropped for shared nodes. var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); if (!visitedNodeIds.add(nodeId)) { + List stubProperties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - List.of(), List.of(), List.of()); + stubProperties, List.of(), List.of()); } + // Depth exhausted — return a leaf with no relations but still carry properties + // so the deepest reachable entities expose their data when include_data=true. if (remainingDepth <= 0) { + List leafProperties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - List.of(), List.of(), List.of()); + leafProperties, List.of(), List.of()); } List outboundRelations = entity.relations().stream() From ce8afc9e9efa322cf0dd638615f8f4e0280a702d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 28 May 2026 18:00:59 +0200 Subject: [PATCH 22/27] feat(core): add a entity graph service and endpoint --- .pre-commit-config.yaml | 27 +- .spotless/eclipse-formatter.xml | 10 + pom.xml | 823 ++++--- .../idp_core/IdpCoreApplication.java | 6 +- .../domain/constant/ValidationMessages.java | 128 +- .../domain/constant/ValidationRegex.java | 2 +- .../entity/EntityAlreadyExistsException.java | 14 +- .../entity/EntityNotFoundException.java | 22 +- .../entity/EntityValidationException.java | 29 +- .../EntityTemplateAlreadyExistsException.java | 27 +- ...mplateIdentifierCannotChangeException.java | 10 +- ...ityTemplateNameAlreadyExistsException.java | 6 +- .../EntityTemplateNotFoundException.java | 95 +- ...pertyDefinitionRulesConflictException.java | 18 +- .../PropertyNameAlreadyExistsException.java | 16 +- .../PropertyTypeChangeException.java | 17 +- .../RelationCannotTargetItselfException.java | 15 +- .../RelationNameAlreadyExistsException.java | 16 +- ...RelationTargetTemplateChangeException.java | 18 +- .../TargetTemplateNotFoundException.java | 14 +- ...pertyDefinitionRulesConflictException.java | 18 +- .../idp_core/domain/model/entity/Entity.java | 41 +- .../model/entity/EntityCompositeKey.java | 50 +- .../domain/model/entity/EntitySummary.java | 3 +- .../domain/model/entity/Property.java | 13 +- .../domain/model/entity/Relation.java | 45 +- .../model/entity/RelationAsTargetSummary.java | 9 +- .../model/entity_graph/EntityGraphNode.java | 21 +- .../entity_graph/EntityGraphRelation.java | 11 +- .../model/entity_template/EntityTemplate.java | 36 +- .../entity_template/PropertyDefinition.java | 19 +- .../model/entity_template/PropertyRules.java | 27 +- .../entity_template/RelationDefinition.java | 12 +- .../domain/model/enums/PropertyFormat.java | 3 +- .../domain/model/enums/PropertyType.java | 4 +- .../port/EntityGraphRepositoryPort.java | 48 +- .../domain/port/EntityRepositoryPort.java | 21 +- .../port/EntityTemplateRepositoryPort.java | 14 +- .../domain/port/RelationRepositoryPort.java | 4 +- .../domain/service/RelationService.java | 29 +- .../domain/service/entity/EntityService.java | 142 +- .../entity/EntityValidationService.java | 114 +- .../domain/service/entity/Violations.java | 44 +- .../entity_graph/EntityGraphService.java | 237 +- .../EntityTemplateService.java | 473 ++-- .../EntityTemplateValidationService.java | 304 +-- .../PropertyDefinitionValidationService.java | 497 ++-- .../PropertyRegexValidationService.java | 553 ++--- .../RelationDefinitionValidationService.java | 156 +- .../property/PropertyValidationService.java | 146 +- .../api/configuration/CorsProperties.java | 24 +- .../api/configuration/JwtConfiguration.java | 15 +- .../configuration/SecurityConfiguration.java | 62 +- .../SpringDataWebConfiguration.java | 5 +- .../configuration/SwaggerConfiguration.java | 94 +- .../api/configuration/SwaggerDescription.java | 294 ++- .../api/configuration/WebConfiguration.java | 8 +- .../api/controller/EntityController.java | 173 +- .../api/controller/EntityGraphController.java | 83 +- .../controller/EntityTemplateController.java | 177 +- .../adapters/api/dto/in/EntityDtoIn.java | 73 +- .../api/dto/in/EntityTemplateCreateDtoIn.java | 26 +- .../in/EntityTemplateDtoInCommonFields.java | 38 +- .../api/dto/in/EntityTemplateUpdateDtoIn.java | 22 +- .../api/dto/in/PropertyDefinitionDtoIn.java | 37 +- .../api/dto/in/PropertyRulesDtoIn.java | 28 +- .../api/dto/in/RelationDefinitionDtoIn.java | 27 +- .../api/dto/out/entity/EntityDtoOut.java | 12 +- .../dto/out/entity/EntityGraphEdgeDtoOut.java | 14 +- .../dto/out/entity/EntityGraphFlatDtoOut.java | 17 +- .../out/entity/EntityGraphNodeFlatDtoOut.java | 36 +- .../api/dto/out/entity/EntitySummaryDto.java | 4 +- .../entity/RelationAsTargetSummaryDtoOut.java | 9 +- .../entity_template/EntityTemplateDtoOut.java | 29 +- .../PropertyDefinitionDtoOut.java | 25 +- .../entity_template/PropertyRulesDtoOut.java | 28 +- .../RelationDefinitionDtoOut.java | 16 +- .../api/handler/ApiExceptionHandler.java | 608 ++--- .../api/mapper/entity/EntityDtoInMapper.java | 60 +- .../api/mapper/entity/EntityDtoOutMapper.java | 505 ++-- .../entity/EntityGraphFlatDtoOutMapper.java | 261 +-- .../entity_template/EntityTemplateMapper.java | 359 ++- .../persistence/PostgresEntityAdapter.java | 88 +- .../PostgresEntityGraphAdapter.java | 75 +- .../PostgresEntityTemplateAdapter.java | 320 ++- .../persistence/PostgresRelationAdapter.java | 13 +- .../mapper/EntityPersistenceMapper.java | 12 +- .../EntityTemplatePersistenceMapper.java | 21 +- .../model/entity/EntityJpaEntity.java | 40 +- .../model/entity/PropertyJpaEntity.java | 15 +- .../model/entity/PropertyRulesJpaEntity.java | 27 +- .../model/entity/RelationJpaEntity.java | 25 +- .../EntityTemplateJpaEntity.java | 89 +- .../PropertyDefinitionJpaEntity.java | 31 +- .../RelationDefinitionJpaEntity.java | 21 +- .../repository/JpaEntityRepository.java | 303 +-- .../JpaEntityTemplateRepository.java | 28 +- .../repository/JpaRelationRepository.java | 22 +- .../idp_core/AbstractIntegrationTest.java | 325 ++- .../idp_core/TestSecurityConfiguration.java | 5 +- .../service/entity/EntityServiceTest.java | 245 +- .../entity/EntityValidationServiceTest.java | 321 ++- .../entity_graph/EntityGraphServiceTest.java | 600 +++-- .../EntityTemplateServiceTest.java | 456 ++-- ...opertyDefinitionValidationServiceTest.java | 1882 ++++++--------- .../PropertyRegexValidationServiceTest.java | 110 +- ...lationDefinitionValidationServiceTest.java | 580 +++-- .../PropertyValidationServiceTest.java | 483 ++-- .../api/controller/EntityControllerTest.java | 426 ++-- .../controller/EntityGraphControllerTest.java | 333 ++- .../EntityTemplateControllerTest.java | 2055 ++++++++--------- .../api/controller/HealthControllerTest.java | 11 +- .../api/handler/ApiExceptionHandlerTest.java | 910 ++++---- .../api/mapper/EntityTemplateMapperTest.java | 1042 ++++----- 114 files changed, 8881 insertions(+), 9609 deletions(-) create mode 100644 .spotless/eclipse-formatter.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 202be12..815a5e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: trailing-whitespace # Trims trailing whitespace + - id: trailing-whitespace # Trims trailing whitespace exclude: | (?x)^( .gitmodules| @@ -12,23 +12,23 @@ repos: .*\.drawio.*| .*\.snap )$ - - id: check-yaml # Validates YAML files + - id: check-yaml # Validates YAML files args: - --allow-multiple-documents - - id: check-json # Validates JSON files - - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems - - id: check-merge-conflict # Checks for files that contain merge conflict strings - - id: detect-private-key # Check for the existence of private keys - - id: check-executables-have-shebangs # Checks that executables have shebangs + - id: check-json # Validates JSON files + - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems + - id: check-merge-conflict # Checks for files that contain merge conflict strings + - id: detect-private-key # Check for the existence of private keys + - id: check-executables-have-shebangs # Checks that executables have shebangs exclude: | (?x)^( .*\.java )$ - - id: end-of-file-fixer # Makes sure files end in a newline and only a newline + - id: end-of-file-fixer # Makes sure files end in a newline and only a newline - repo: https://github.com/adrienverge/yamllint rev: v1.37.1 hooks: - - id: yamllint # Lints YAML files + - id: yamllint # Lints YAML files - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.47.0 hooks: @@ -46,3 +46,12 @@ repos: args: [sync] - id: vale args: [--output=line, --minAlertLevel=error] + - repo: local + hooks: + - id: spotless-check + name: Spotless code formatting check + entry: ./mvnw spotless:check + language: system + pass_filenames: false + files: \.java$ + stages: [commit] diff --git a/.spotless/eclipse-formatter.xml b/.spotless/eclipse-formatter.xml new file mode 100644 index 0000000..75b546e --- /dev/null +++ b/.spotless/eclipse-formatter.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1844818..d6b59c0 100644 --- a/pom.xml +++ b/pom.xml @@ -1,388 +1,447 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 4.0.5 - - - - com.decathlon - idp-core - 0.0.1-SNAPSHOT - idp-core - IDP core component - - - 25 - 2.2.48 - 1.18.44 - 5.15.0 - 1.21.4 - 3.15.0 - 0.8.14 - 1.5.5.Final - - - - - - - io.swagger.core.v3 - swagger-core-jakarta - ${jakarta.version} - - - io.swagger.core.v3 - swagger-annotations-jakarta - ${jakarta.version} - - - io.swagger.core.v3 - swagger-models-jakarta - ${jakarta.version} - - - org.apache.tomcat.embed - tomcat-embed-core - - - org.apache.tomcat.embed - tomcat-embed-websocket - - - org.apache.tomcat.embed - tomcat-embed-el - - - - + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.5 + + + + com.decathlon + idp-core + 0.0.1-SNAPSHOT + idp-core + IDP core component + + + 25 + 2.2.48 + 1.18.44 + 5.15.0 + 1.21.4 + 3.15.0 + 0.8.14 + 1.5.5.Final + + + + - - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-starter-webflux - - - - org.springframework.boot - spring-boot-starter-validation - - - - org.springframework.boot - spring-boot-starter-oauth2-authorization-server - - - - org.projectlombok - lombok - ${lombok.version} - provided - - - - - org.mapstruct - mapstruct - ${mapstruct.version} - - - org.mapstruct - mapstruct-processor - ${mapstruct.version} - provided - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - org.springframework.boot - spring-boot-devtools - runtime - true - - - - - org.springframework.boot - spring-boot-starter-flyway - - - org.flywaydb - flyway-database-postgresql - - - - - org.postgresql - postgresql - runtime - - - - - com.h2database - h2 - runtime - - - - - org.springframework.boot - spring-boot-starter-test - test - - - - org.mock-server - mockserver-client-java - ${mockserver.version} - test - - - - org.mock-server - mockserver-netty - ${mockserver.version} - test - - - - org.springframework.boot - spring-boot-starter-webmvc-test - test - - - - org.testcontainers - testcontainers - ${testcontainers.version} - test - - - - org.testcontainers - postgresql - ${testcontainers.version} - test - - - - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - - - - org.springframework.boot - spring-boot-starter-security-test - test - - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 3.0.3 - - - - - io.swagger.parser.v3 - swagger-parser - 2.1.40 - test - - - io.swagger.core.v3 - swagger-core - - - io.swagger - swagger-core - - - - - - org.apache.commons - commons-lang3 - 3.20.0 - - - - org.springframework.boot - spring-boot-starter-actuator - - + + io.swagger.core.v3 + swagger-core-jakarta + ${jakarta.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + ${jakarta.version} + + + io.swagger.core.v3 + swagger-models-jakarta + ${jakarta.version} + + + org.apache.tomcat.embed + tomcat-embed-core + + + org.apache.tomcat.embed + tomcat-embed-websocket + + + org.apache.tomcat.embed + tomcat-embed-el + - - - - - org.springframework.boot - spring-boot-maven-plugin - - com.decathlon.idp_core.IdpCoreApplication - - true - - - - org.projectlombok - lombok - - - - - - - org.sonarsource.scanner.maven - sonar-maven-plugin - 3.9.1.2184 - - - - org.flywaydb - flyway-maven-plugin - 9.12.0 - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven-compiler.version} - - 25 - - - org.projectlombok - lombok - ${lombok.version} - - - org.mapstruct - mapstruct-processor - ${mapstruct.version} - - - - org.projectlombok - lombok-mapstruct-binding - 0.2.0 - - - - - - - org.jacoco - jacoco-maven-plugin - ${jacoco-maven.version} - - - prepare-agent - - prepare-agent - - - - report - - report - - - - XML - - - - - - - - com.github.spotbugs - spotbugs-maven-plugin - 4.9.8.2 - - true - ${project.build.directory} - false - - - - verify - - check - - - - - - - com.github.codemonstur - maven-check-license - 1.2.0 - - - validate - - check - - - - - true - passOnMatch - - name:equal:MIT - name:equal:MIT License - name:equal:The MIT License - name:equal:MIT-0 - name:equal:Apache-2.0 - name:equal:Apache License 2.0 - name:equal:The Apache License, Version 2.0 - name:equal:The Apache Software License, Version 2.0 - name:equal:Apache License Version 2.0 - name:equal:Apache License, Version 2.0 - name:regex:Apache(\s|-)(Software )?(License |License, + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql + + + + + org.postgresql + postgresql + runtime + + + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.mock-server + mockserver-client-java + ${mockserver.version} + test + + + + org.mock-server + mockserver-netty + ${mockserver.version} + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.3 + + + + + io.swagger.parser.v3 + swagger-parser + 2.1.40 + test + + + io.swagger.core.v3 + swagger-core + + + io.swagger + swagger-core + + + + + + org.apache.commons + commons-lang3 + 3.20.0 + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.decathlon.idp_core.IdpCoreApplication + + true + + + + org.projectlombok + lombok + + + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + 3.9.1.2184 + + + + org.flywaydb + flyway-maven-plugin + 9.12.0 + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler.version} + + 25 + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven.version} + + + prepare-agent + + prepare-agent + + + + report + + report + + + + XML + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.8.2 + + true + ${project.build.directory} + false + + + + + check + + verify + + + + + + com.github.codemonstur + maven-check-license + 1.2.0 + + true + passOnMatch + + name:equal:MIT + name:equal:MIT License + name:equal:The MIT License + name:equal:MIT-0 + name:equal:Apache-2.0 + name:equal:Apache License 2.0 + name:equal:The Apache License, Version 2.0 + name:equal:The Apache Software License, Version 2.0 + name:equal:Apache License Version 2.0 + name:equal:Apache License, Version 2.0 + name:regex:Apache(\s|-)(Software )?(License |License, )?(Version|version )?2\.0 - name:equal:EPL 2.0 - name:equal:Eclipse Public License - v 2.0 - name:equal:Eclipse Distribution License - v 1.0 - name:equal:Eclipse Distribution License v. 1.0 - name:equal:EDL 1.0 - name:equal:BSD-3-Clause - name:equal:BSD-2-Clause - name:equal:MPL 2.0 - name:equal:EPL 1.0 - name:equal:Public Domain, per Creative Commons CC0 - - - org.hibernate.orm:hibernate-core:6.6.29.Final - ch.qos.logback:logback-classic:1.5.25 - ch.qos.logback:logback-core:1.5.25 - - - - - - + name:equal:EPL 2.0 + name:equal:Eclipse Public License - v 2.0 + name:equal:Eclipse Distribution License - v 1.0 + name:equal:Eclipse Distribution License v. 1.0 + name:equal:EDL 1.0 + name:equal:BSD-3-Clause + name:equal:BSD-2-Clause + name:equal:MPL 2.0 + name:equal:EPL 1.0 + name:equal:Public Domain, per Creative Commons CC0 + + + org.hibernate.orm:hibernate-core:6.6.29.Final + ch.qos.logback:logback-classic:1.5.25 + ch.qos.logback:logback-core:1.5.25 + + + + + + check + + validate + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.30.0 + + + + + + 4.21.0 + ${project.basedir}/.spotless/eclipse-formatter.xml + + + + + java,javax,jakarta,org,com + + + + + src/main/java/**/*.java + src/test/java/**/*.java + + + + + + + false + + + + + + + + src/main/resources/**/*.yml + src/main/resources/**/*.yaml + src/test/resources/**/*.yml + src/test/resources/**/*.yaml + + + + + true + 2 + + + + + + + check-formatting + + check + + verify + + + + + + diff --git a/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java b/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java index 27eeea9..3c5bb4d 100644 --- a/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java +++ b/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class IdpCoreApplication { - public static void main(String[] args) { - SpringApplication.run(IdpCoreApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(IdpCoreApplication.class, args); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 659e912..cbdf466 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -6,78 +6,74 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValidationMessages { - // Entity Template validation messages - public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; - public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; - public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; - public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; - public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; - public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; - public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; - public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + // Entity Template validation messages + public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; + public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; + public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; + public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; + public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; + public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; + public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; + public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; - // Property Definition validation messages - public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; - public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; - public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; - public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; - public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; - public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; - public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; - public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; - public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; - public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; - public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; - public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; - public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; - public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; + // Property Definition validation messages + public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; + public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; + public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; + public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; + public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; - // Relation Definition validation messages - public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; - public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; - public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; - public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; - public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; - public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; + // Relation Definition validation messages + public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; + public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; + public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; + public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; + public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; - // Property Rules validation messages - templates and specific constraints - public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; - public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; - public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; - public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; - // Entity input validation messages - public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; - public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; - // Entity creation validation messages - public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; - public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; - public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; - // Helper method to construct rules incompatibility message - public static String rulesAreIncompatible(String rule1, String rule2) { - return PROPERTY_RULES_MUTUALLY_EXCLUSIVE - .replace("{rule1}", rule1) - .replace("{rule2}", rule2); - } + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE.replace("{rule1}", rule1).replace("{rule2}", rule2); + } - // Helper method to construct rule-not-allowed message - public static String ruleNotAllowed(String rule, String propertyType) { - return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE - .replace("{rule}", rule) - .replace("{type}", propertyType); - } + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE.replace("{rule}", rule).replace("{type}", + propertyType); + } - // Helper method to construct min/max constraint violation message - public static String minMaxConstraintViolated(String constraint) { - return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED - .replace("{constraint}", constraint); - } + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java index 42047e8..0ea0298 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java @@ -6,6 +6,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValidationRegex { - public static final String ENTITY_TEMPLATE_NAME_REGEX = "^[a-zA-Z0-9 _-]+$"; + public static final String ENTITY_TEMPLATE_NAME_REGEX = "^[a-zA-Z0-9 _-]+$"; } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index 8243748..fb139cf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -16,11 +16,11 @@ /// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityName the duplicate entity name - public EntityAlreadyExistsException(String templateIdentifier, String entityName) { - super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index 42c60f6..cea5f8e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -16,15 +16,17 @@ /// - Maintains template-entity relationship integrity public class EntityNotFoundException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// **Why this exists:** Provides standardized error message format that includes - /// both template and entity context for clear debugging and API error responses. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityIdentifier the identifier of the entity - public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// **Why this exists:** Provides standardized error message format that + /// includes + /// both template and entity context for clear debugging and API error + /// responses. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityIdentifier the identifier of the entity + public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index 42756f0..0038120 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -23,21 +23,20 @@ @Getter public class EntityValidationException extends RuntimeException { - /** - * -- GETTER -- - * Returns the list of individual validation violation messages. - * /// - * /// - * @return immutable list of violation messages - */ - private final List violations; + /** + * -- GETTER -- Returns the list of individual validation violation messages. + * /// /// + * + * @return immutable list of violation messages + */ + private final List violations; - /// Constructs a new exception with a list of validation violation messages. - /// - /// @param violations the list of validation error messages - public EntityValidationException(List violations) { - super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); - this.violations = List.copyOf(violations); - } + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index c389889..2cceb68 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -23,16 +23,19 @@ /// - Contains specific identifier that caused the conflict for debugging public class EntityTemplateAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with the specific identifier that already exists. - /// - /// **Why this constructor exists:** - /// - Formats exception message to include the duplicate identifier for clear debugging - /// - Provides consistent error messaging across the application - /// - Enables API consumers to understand which specific identifier caused the conflict - /// - /// @param identifier the identifier that already exists in the system, must not be null - /// @throws IllegalArgumentException if identifier is null - public EntityTemplateAlreadyExistsException(String identifier) { - super(String.format(TEMPLATE_ALREADY_EXISTS + ":%s", identifier)); - } + /// Constructs a new exception with the specific identifier that already exists. + /// + /// **Why this constructor exists:** + /// - Formats exception message to include the duplicate identifier for clear + /// debugging + /// - Provides consistent error messaging across the application + /// - Enables API consumers to understand which specific identifier caused the + /// conflict + /// + /// @param identifier the identifier that already exists in the system, must not + /// be null + /// @throws IllegalArgumentException if identifier is null + public EntityTemplateAlreadyExistsException(String identifier) { + super(String.format(TEMPLATE_ALREADY_EXISTS + ":%s", identifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java index 3d0a149..b6bb002 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -1,11 +1,11 @@ package com.decathlon.idp_core.domain.exception.entity_template; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; - /// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. /// /// **Why this exception exists:** @@ -19,7 +19,7 @@ /// - Contains the identifier that was attempted to be changed for debugging public class EntityTemplateIdentifierCannotChangeException extends RuntimeException { - public EntityTemplateIdentifierCannotChangeException(String identifier) { - super(TEMPLATE_IDENTIFIER_CANNOT_CHANGE + identifier); - } + public EntityTemplateIdentifierCannotChangeException(String identifier) { + super(TEMPLATE_IDENTIFIER_CANNOT_CHANGE + identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index 12e3457..9732ab2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -19,7 +19,7 @@ /// - Contains specific name that caused the conflict for debugging public class EntityTemplateNameAlreadyExistsException extends RuntimeException { - public EntityTemplateNameAlreadyExistsException(String name) { - super(String.format(TEMPLATE_NAME_ALREADY_EXISTS, name)); - } + public EntityTemplateNameAlreadyExistsException(String name) { + super(String.format(TEMPLATE_NAME_ALREADY_EXISTS, name)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java index c765a4f..4fe2c82 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java @@ -21,54 +21,57 @@ /// - Template management operations public class EntityTemplateNotFoundException extends RuntimeException { - /// Default constructor for generic template not found scenarios. - /// - /// **Why this exists:** Provides a fallback when specific template details - /// are not available but the business rule violation still needs to be reported. - public EntityTemplateNotFoundException() { - super("Template not found"); - } + /// Default constructor for generic template not found scenarios. + /// + /// **Why this exists:** Provides a fallback when specific template details + /// are not available but the business rule violation still needs to be + /// reported. + public EntityTemplateNotFoundException() { + super("Template not found"); + } - /// Constructs a new exception with a custom error message. - /// - /// **Why this exists:** Allows for specific error messages that provide more - /// context about the search criteria or operation that failed. - /// - /// @param message the detail message explaining what was not found - public EntityTemplateNotFoundException(String message) { - super(message); - } + /// Constructs a new exception with a custom error message. + /// + /// **Why this exists:** Allows for specific error messages that provide more + /// context about the search criteria or operation that failed. + /// + /// @param message the detail message explaining what was not found + public EntityTemplateNotFoundException(String message) { + super(message); + } - /// Constructs a new exception for a specific UUID-based lookup. - /// - /// **Why this exists:** Provides standardized error message format when - /// searching for a template by its primary key identifier. - /// - /// @param id the UUID of the template that was not found - public EntityTemplateNotFoundException(UUID id) { - super("Template not found with ID: " + id); - } + /// Constructs a new exception for a specific UUID-based lookup. + /// + /// **Why this exists:** Provides standardized error message format when + /// searching for a template by its primary key identifier. + /// + /// @param id the UUID of the template that was not found + public EntityTemplateNotFoundException(UUID id) { + super("Template not found with ID: " + id); + } - /// Constructs a new exception for field-based searches. - /// - /// **Why this exists:** Commonly used for business identifier searches where - /// the field name (for example, "identifier") and its value are known, providing - /// clear context about what search criteria failed. - /// - /// @param fieldName the name of the field used in the search (for example, "identifier") - /// @param value the value that was searched for but not found - public EntityTemplateNotFoundException(String fieldName, String value) { - super("Template not found with " + fieldName + ": " + value); - } + /// Constructs a new exception for field-based searches. + /// + /// **Why this exists:** Commonly used for business identifier searches where + /// the field name (for example, "identifier") and its value are known, + /// providing + /// clear context about what search criteria failed. + /// + /// @param fieldName the name of the field used in the search (for example, + /// "identifier") + /// @param value the value that was searched for but not found + public EntityTemplateNotFoundException(String fieldName, String value) { + super("Template not found with " + fieldName + ": " + value); + } - /// Constructs a new exception with a custom message and underlying cause. - /// - /// **Why this exists:** Used when the exception wraps another exception or - /// when additional context about the underlying cause is needed for debugging. - /// - /// @param message the detail message explaining what was not found - /// @param cause the underlying cause of this exception - public EntityTemplateNotFoundException(String message, Throwable cause) { - super(message, cause); - } + /// Constructs a new exception with a custom message and underlying cause. + /// + /// **Why this exists:** Used when the exception wraps another exception or + /// when additional context about the underlying cause is needed for debugging. + /// + /// @param message the detail message explaining what was not found + /// @param cause the underlying cause of this exception + public EntityTemplateNotFoundException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java index 650637d..2ce1db4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java @@ -13,13 +13,13 @@ /// - Property template updates introducing rule conflicts public class PropertyDefinitionRulesConflictException extends RuntimeException { - /// Constructs a new exception for rule type conflict. - /// - /// @param propertyName the name of the property with invalid rules - /// @param propertyType the data type of the property - /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { - super("Property '" + propertyName + "' of type " + propertyType + - ": " + violationMessage); - } + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java index 2399926..54ccc36 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java @@ -1,9 +1,9 @@ package com.decathlon.idp_core.domain.exception.entity_template; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_ALREADY_EXISTS; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Exception thrown when attempting to create or update an [EntityTemplate] with duplicate property names. /// /// This exception represents a business rule violation where unique constraints on property @@ -15,10 +15,10 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class PropertyNameAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with the duplicate property name. - /// - /// @param propertyName the property name that appears more than once - public PropertyNameAlreadyExistsException(String propertyName) { - super(String.format(PROPERTY_NAME_ALREADY_EXISTS, propertyName)); - } + /// Constructs a new exception with the duplicate property name. + /// + /// @param propertyName the property name that appears more than once + public PropertyNameAlreadyExistsException(String propertyName) { + super(String.format(PROPERTY_NAME_ALREADY_EXISTS, propertyName)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java index b543272..44d8d2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java @@ -15,12 +15,13 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class PropertyTypeChangeException extends RuntimeException { - /// Constructs a new exception for a type conversion. - /// - /// @param propertyName the name of the property whose type is being changed - /// @param fromType the current property type - /// @param toType the requested new property type - public PropertyTypeChangeException(String propertyName, PropertyType fromType, PropertyType toType) { - super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType)); - } + /// Constructs a new exception for a type conversion. + /// + /// @param propertyName the name of the property whose type is being changed + /// @param fromType the current property type + /// @param toType the requested new property type + public PropertyTypeChangeException(String propertyName, PropertyType fromType, + PropertyType toType) { + super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java index 0143ca4..978a136 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java @@ -11,11 +11,12 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class RelationCannotTargetItselfException extends RuntimeException { - /// Constructs a new exception for a self-referential relation attempt. - /// - /// @param relationName the name of the relation pointing to its own template - /// @param templateIdentifier the identifier of the template that is both owner and target - public RelationCannotTargetItselfException(String relationName, String templateIdentifier) { - super(String.format(RELATION_CANNOT_TARGET_ITSELF, relationName, templateIdentifier)); - } + /// Constructs a new exception for a self-referential relation attempt. + /// + /// @param relationName the name of the relation pointing to its own template + /// @param templateIdentifier the identifier of the template that is both owner + /// and target + public RelationCannotTargetItselfException(String relationName, String templateIdentifier) { + super(String.format(RELATION_CANNOT_TARGET_ITSELF, relationName, templateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java index 76cf4a6..97d5f99 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java @@ -1,9 +1,9 @@ package com.decathlon.idp_core.domain.exception.entity_template; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_ALREADY_EXISTS; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Exception thrown when attempting to create or update an [EntityTemplate] with duplicate relation names. /// /// This exception represents a business rule violation where unique constraints on relation @@ -15,10 +15,10 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class RelationNameAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with the duplicate relation name. - /// - /// @param relationName the relation name that appears more than once - public RelationNameAlreadyExistsException(String relationName) { - super(String.format(RELATION_NAME_ALREADY_EXISTS, relationName)); - } + /// Constructs a new exception with the duplicate relation name. + /// + /// @param relationName the relation name that appears more than once + public RelationNameAlreadyExistsException(String relationName) { + super(String.format(RELATION_NAME_ALREADY_EXISTS, relationName)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java index bd194f2..36a6c5e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java @@ -14,12 +14,14 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class RelationTargetTemplateChangeException extends RuntimeException { - /// Constructs a new exception for a target template change attempt. - /// - /// @param relationName the name of the relation whose target is being changed - /// @param fromTarget the current target template identifier - /// @param toTarget the requested new target template identifier - public RelationTargetTemplateChangeException(String relationName, String fromTarget, String toTarget) { - super(String.format(RELATION_TARGET_TEMPLATE_CANNOT_CHANGE, relationName, fromTarget, toTarget)); - } + /// Constructs a new exception for a target template change attempt. + /// + /// @param relationName the name of the relation whose target is being changed + /// @param fromTarget the current target template identifier + /// @param toTarget the requested new target template identifier + public RelationTargetTemplateChangeException(String relationName, String fromTarget, + String toTarget) { + super( + String.format(RELATION_TARGET_TEMPLATE_CANNOT_CHANGE, relationName, fromTarget, toTarget)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java index df60b3c..bc82fd6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java @@ -13,10 +13,12 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class TargetTemplateNotFoundException extends RuntimeException { - /// Constructs a new exception with the target template identifier that was not found. - /// - /// @param targetTemplateIdentifier the identifier of the target template that doesn't exist - public TargetTemplateNotFoundException(String targetTemplateIdentifier) { - super(String.format(TEMPLATE_IDENTIFIER_NOT_FOUND, targetTemplateIdentifier)); - } + /// Constructs a new exception with the target template identifier that was not + /// found. + /// + /// @param targetTemplateIdentifier the identifier of the target template that + /// doesn't exist + public TargetTemplateNotFoundException(String targetTemplateIdentifier) { + super(String.format(TEMPLATE_IDENTIFIER_NOT_FOUND, targetTemplateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index 3ce489e..737c7c8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -13,13 +13,13 @@ /// - Property template updates introducing rule conflicts public class PropertyDefinitionRulesConflictException extends RuntimeException { - /// Constructs a new exception for rule type conflict. - /// - /// @param propertyName the name of the property with invalid rules - /// @param propertyType the data type of the property - /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { - super("Property '" + propertyName + "' of type " + propertyType + - ": " + violationMessage); - } + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2292ecd..648df7a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,10 +7,10 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import jakarta.validation.constraints.NotBlank; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Domain entity representing a concrete instance of an [EntityTemplate]. /// /// Business invariants: @@ -22,24 +22,21 @@ /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. -public record Entity( - UUID id, - - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - String templateIdentifier, - @NotBlank(message = ENTITY_NAME_MANDATORY) - String name, - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) - String identifier, - - List properties, - - List relations -) { - /// Compact constructor: defensively copies mutable lists to prevent external mutation - /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). - public Entity { - properties = properties == null ? List.of() : List.copyOf(properties); - relations = relations == null ? List.of() : List.copyOf(relations); - } +public record Entity(UUID id, + + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, + + List properties, + + List relations) { + /// Compact constructor: defensively copies mutable lists to prevent external + /// mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / + /// EI_EXPOSE_REP). + public Entity { + properties = properties == null ? List.of() : List.copyOf(properties); + relations = relations == null ? List.of() : List.copyOf(relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java index 30a0f99..db38bde 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -3,34 +3,36 @@ import java.util.Objects; /** - * Composite key for uniquely identifying an entity across templates. - * Since the same identifier can exist in different templates, we need both fields. + * Composite key for uniquely identifying an entity across templates. Since the + * same identifier can exist in different templates, we need both fields. */ public record EntityCompositeKey(String templateIdentifier, String identifier) { - public static EntityCompositeKey fromString(String compositeKey) { - String[] parts = compositeKey.split(":", 2); - if (parts.length != 2) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - return new EntityCompositeKey(parts[0], parts[1]); + public static EntityCompositeKey fromString(String compositeKey) { + String[] parts = compositeKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); } + return new EntityCompositeKey(parts[0], parts[1]); + } - @Override - public String toString() { - return templateIdentifier + ":" + identifier; - } + @Override + public String toString() { + return templateIdentifier + ":" + identifier; + } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - EntityCompositeKey that = (EntityCompositeKey) o; - return Objects.equals(templateIdentifier, that.templateIdentifier) && - Objects.equals(identifier, that.identifier); - } + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + EntityCompositeKey that = (EntityCompositeKey) o; + return Objects.equals(templateIdentifier, that.templateIdentifier) + && Objects.equals(identifier, that.identifier); + } - @Override - public int hashCode() { - return Objects.hash(templateIdentifier, identifier); - } + @Override + public int hashCode() { + return Objects.hash(templateIdentifier, identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java index d4b3569..353afb4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java @@ -14,4 +14,5 @@ /// - Relationship target references /// - Performance-optimized read operations where full entity data isn't required @Builder -public record EntitySummary(String identifier, String name, String templateIdentifier) {} +public record EntitySummary(String identifier, String name, String templateIdentifier) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 5e7281e..85cafcc 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -4,12 +4,12 @@ import java.util.UUID; +import jakarta.validation.constraints.NotBlank; + import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import jakarta.validation.constraints.NotBlank; - /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints defined @@ -23,12 +23,9 @@ /// - Property values must be typed according to the template's [PropertyType] definition /// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved /// for strict type-mismatch detection at validation time). -public record Property( - UUID id, +public record Property(UUID id, - @NotBlank(message = PROPERTY_NAME_MANDATORY) - String name, + @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - String value -) { + String value) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java index e9ae654..f5c9a2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java @@ -7,12 +7,12 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; + /// A concrete relationship instance connecting entities in the business domain. /// /// Represents actual business connections between entities that conform to the @@ -25,25 +25,22 @@ /// - Required relations cannot have empty target lists /// - Multiple targets allowed only when template's `toMany` is true /// - Target template identifiers must reference valid [EntityTemplate] identifiers -public record Relation( - UUID id, - - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - String name, - - @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) - String targetTemplateIdentifier, - - @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) - List targetEntityIdentifiers -) { - /// Ensures immutable defensive copying of target entity identifiers. - /// - /// **Why this exists:** Prevents external mutation of relationship targets after - /// construction, maintaining referential integrity in the business object graph. - public Relation { - targetEntityIdentifiers = targetEntityIdentifiers != null - ? List.copyOf(targetEntityIdentifiers) - : List.of(); - } +public record Relation(UUID id, + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) String name, + + @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) String targetTemplateIdentifier, + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) List targetEntityIdentifiers) { + /// Ensures immutable defensive copying of target entity identifiers. + /// + /// **Why this exists:** Prevents external mutation of relationship targets + /// after + /// construction, maintaining referential integrity in the business object + /// graph. + public Relation { + targetEntityIdentifiers = targetEntityIdentifiers != null + ? List.copyOf(targetEntityIdentifiers) + : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java index a1a5ea6..b38af3e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java @@ -11,9 +11,6 @@ /// - Dependency impact analysis before entity deletion /// - Bidirectional relationship navigation /// - Audit trails for relationship changes -public record RelationAsTargetSummary( - String targetEntityIdentifier, - String relationName, - String sourceEntityIdentifier, - String sourceEntityName -) {} +public record RelationAsTargetSummary(String targetEntityIdentifier, String relationName, + String sourceEntityIdentifier, String sourceEntityName) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index fff3564..8b3266b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -18,17 +18,12 @@ /// @param properties the entity's property instances; empty when not requested /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target -public record EntityGraphNode( - String templateIdentifier, - String identifier, - String name, - List properties, - List relations, - List relationsAsTarget -) { - public EntityGraphNode { - properties = properties != null ? List.copyOf(properties) : List.of(); - relations = relations != null ? List.copyOf(relations) : List.of(); - relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); - } +public record EntityGraphNode(String templateIdentifier, String identifier, String name, + List properties, List relations, + List relationsAsTarget) { + public EntityGraphNode { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java index d770639..e9b25fe 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -11,11 +11,8 @@ /// @param name the relation name as defined in the entity template /// @param targetTemplateIdentifier the template identifier of the target entities /// @param targets the resolved target entity graph nodes (recursively populated up to depth) -public record EntityGraphRelation( - String name, - List targets -) { - public EntityGraphRelation { - targets = targets != null ? List.copyOf(targets) : List.of(); - } +public record EntityGraphRelation(String name, List targets) { + public EntityGraphRelation { + targets = targets != null ? List.copyOf(targets) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 2d694f1..9dc59ce 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -26,27 +26,27 @@ /// - Relation names must be unique within the template (if any) /// - All property definitions must have valid types and constraints /// - Relations must reference valid target template identifiers -public record EntityTemplate( - UUID id, +public record EntityTemplate(UUID id, - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - String identifier, + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String identifier, - @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) - @NotBlank(message = TEMPLATE_NAME_MANDATORY) - @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) - String name, + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) @NotBlank(message = TEMPLATE_NAME_MANDATORY) @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) String name, - String description, + String description, - List propertiesDefinitions, + List propertiesDefinitions, - List relationsDefinitions -) { - /// Compact constructor: defensively copies mutable lists to prevent external mutation - /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). - public EntityTemplate { - propertiesDefinitions = propertiesDefinitions == null ? List.of() : List.copyOf(propertiesDefinitions); - relationsDefinitions = relationsDefinitions == null ? List.of() : List.copyOf(relationsDefinitions); - } + List relationsDefinitions) { + /// Compact constructor: defensively copies mutable lists to prevent external + /// mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / + /// EI_EXPOSE_REP). + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions == null + ? List.of() + : List.copyOf(propertiesDefinitions); + relationsDefinitions = relationsDefinitions == null + ? List.of() + : List.copyOf(relationsDefinitions); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java index 5167811..f96b108 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java @@ -6,11 +6,11 @@ import java.util.UUID; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + /// Defines the structure and constraints for a property within an [EntityTemplate]. /// /// Part of the domain's ubiquitous language where each property represents a business @@ -22,20 +22,15 @@ /// - Required properties cannot be null/empty when creating entities /// - Validation rules in [PropertyRules] are enforced for all property values /// - Property descriptions support business documentation and user guidance -public record PropertyDefinition( - UUID id, +public record PropertyDefinition(UUID id, - @NotBlank(message = PROPERTY_NAME_MANDATORY) - String name, + @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) - String description, + @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) String description, - @NotNull(message = PROPERTY_TYPE_MANDATORY) - PropertyType type, + @NotNull(message = PROPERTY_TYPE_MANDATORY) PropertyType type, boolean required, - PropertyRules rules -) { + PropertyRules rules) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java index cd4a30e..ffc7df1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java @@ -17,21 +17,14 @@ /// - Numeric constraints: `minValue` ≤ actual value ≤ `maxValue` /// - Enumeration constraints: values must be in `enumValues` list when specified /// - Regular expression patterns provide additional validation when `regex` is defined -public record PropertyRules( - UUID id, - PropertyFormat format, - List enumValues, - String regex, - Integer maxLength, - Integer minLength, - Integer maxValue, - Integer minValue -) { - /// Ensures immutable defensive copying of enumeration values. - /// - /// **Why this exists:** Prevents external mutation of enum constraints after construction, - /// maintaining business rule integrity throughout the entity lifecycle. - public PropertyRules { - enumValues = enumValues != null ? List.copyOf(enumValues) : null; - } +public record PropertyRules(UUID id, PropertyFormat format, List enumValues, String regex, + Integer maxLength, Integer minLength, Integer maxValue, Integer minValue) { + /// Ensures immutable defensive copying of enumeration values. + /// + /// **Why this exists:** Prevents external mutation of enum constraints after + /// construction, + /// maintaining business rule integrity throughout the entity lifecycle. + public PropertyRules { + enumValues = enumValues != null ? List.copyOf(enumValues) : null; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java index 2a736a9..3f02fa6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java @@ -19,17 +19,13 @@ /// - Required relations cannot be null when creating entities /// - `toMany` relationships allow multiple target connections (one-to-many/many-to-many) /// - `!toMany` relationships enforce single target connections (one-to-one/many-to-one) -public record RelationDefinition( - UUID id, +public record RelationDefinition(UUID id, - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - String name, + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) String name, - @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) - String targetTemplateIdentifier, + @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) String targetTemplateIdentifier, boolean required, - boolean toMany -) { + boolean toMany) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java index c40981b..3022c88 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java @@ -13,6 +13,5 @@ /// - Provides consistent validation across the domain /// - Supports integration with external systems requiring specific formats public enum PropertyFormat { - URL, - EMAIL + URL, EMAIL } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java index d4e7530..0e12913 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java @@ -13,7 +13,5 @@ /// - Provides consistent data representation across persistence and APIs /// - Supports validation rule application based on data type public enum PropertyType { - STRING, - NUMBER, - BOOLEAN + STRING, NUMBER, BOOLEAN } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 82996a2..b081a33 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -21,26 +21,30 @@ /// as this port performs no write operations. public interface EntityGraphRepositoryPort { - /// Fetches all entities in the relationship graph rooted at the given composite key. - /// - /// Uses a recursive CTE to traverse both outbound and inbound relations up to the - /// specified depth, then batch-loads all entities in a minimal number of queries. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity within its template - /// @param depth the maximum traversal depth (1-10) - /// @param includeProperties when true, entity properties are loaded along with relations; - /// when false, only relations are fetched for a leaner query - /// @param relationNames when non-empty, only edges whose relation name is in this set are - /// traversed; when empty, all relation types are followed - /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found - /// Relation name filtering is intentionally NOT pushed into this port. - /// The CTE always traverses all relation types so that nodes reachable via - /// any path are loaded. Edge filtering is applied in the service layer so - /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. - Map findEntityGraph( - String templateIdentifier, - String entityIdentifier, - int depth, - boolean includeProperties); + /// Fetches all entities in the relationship graph rooted at the given composite + /// key. + /// + /// Uses a recursive CTE to traverse both outbound and inbound relations up to + /// the + /// specified depth, then batch-loads all entities in a minimal number of + /// queries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity within + /// its template + /// @param depth the maximum traversal depth (1-10) + /// @param includeProperties when true, entity properties are loaded along with + /// relations; + /// when false, only relations are fetched for a leaner query + /// @param relationNames when non-empty, only edges whose relation name is in + /// this set are + /// traversed; when empty, all relation types are followed + /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if + /// root not found + /// Relation name filtering is intentionally NOT pushed into this port. + /// The CTE always traverses all relation types so that nodes reachable via + /// any path are loaded. Edge filtering is applied in the service layer so + /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. + Map findEntityGraph(String templateIdentifier, + String entityIdentifier, int depth, boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 7ba98f5..05a005f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -27,21 +27,24 @@ /// appropriately for the underlying persistence technology. public interface EntityRepositoryPort { - Entity save(Entity entity); + Entity save(Entity entity); - Optional findById(UUID id); + Optional findById(UUID id); - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); - Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - List findByIdentifierIn(List identifiers); + List findByIdentifierIn(List identifiers); - List findByRelationIdIn(List relationIds); + List findByRelationIdIn(List relationIds); - void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); + void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames); - void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java index 6213370..5f4f910 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java @@ -22,17 +22,17 @@ /// and handle referential integrity with existing entities. public interface EntityTemplateRepositoryPort { - Optional findByIdentifier(String templateIdentifier); + Optional findByIdentifier(String templateIdentifier); - Optional findById(UUID id); + Optional findById(UUID id); - Page findAll(Pageable pageable); + Page findAll(Pageable pageable); - boolean existsByIdentifier(String identifier); + boolean existsByIdentifier(String identifier); - boolean existsByName(String name); + boolean existsByName(String name); - EntityTemplate save(EntityTemplate entityTemplate); + EntityTemplate save(EntityTemplate entityTemplate); - void deleteByIdentifier(String identifier); + void deleteByIdentifier(String identifier); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java index 9216092..09cad71 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java @@ -16,6 +16,6 @@ /// and bidirectional navigation through the entity relationship graph. public interface RelationRepositoryPort { - List findRelationsSummariesByTargetEntityIdentifiers( - List targetEntityIdentifiers); + List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java b/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java index bdc934a..79a8c11 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java @@ -23,18 +23,21 @@ @AllArgsConstructor public class RelationService { - private final RelationRepositoryPort relationRepository; - - /// Finds all incoming relationships where specified entities are targets. - /// - /// **Contract:** Returns relationship summaries for dependency analysis and - /// impact assessment. Useful for understanding entity interconnections before - /// deletion or modification operations. - /// - /// @param targetEntityIdentifiers business identifiers of entities to analyze - /// @return relationship summaries showing incoming connections to target entities - public List findRelationsSummariesByTargetEntityIdentifiers(List targetEntityIdentifiers) { - return relationRepository.findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); - } + private final RelationRepositoryPort relationRepository; + + /// Finds all incoming relationships where specified entities are targets. + /// + /// **Contract:** Returns relationship summaries for dependency analysis and + /// impact assessment. Useful for understanding entity interconnections before + /// deletion or modification operations. + /// + /// @param targetEntityIdentifiers business identifiers of entities to analyze + /// @return relationship summaries showing incoming connections to target + /// entities + public List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers) { + return relationRepository + .findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 72e40ad..e11a767 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,6 +2,9 @@ import java.util.List; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -18,8 +21,6 @@ import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. @@ -37,74 +38,83 @@ @Validated @RequiredArgsConstructor public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityValidationService entityValidationService; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityTemplateService entityTemplateService; - - /// Retrieves entities filtered by template with existence validation. - /// - /// **Contract:** Returns paginated entities that conform to the specified template. - /// Template existence is validated first to ensure meaningful results. - /// - /// @param pageable pagination configuration for large entity sets - /// @param templateIdentifier business identifier of the entity template - /// @return paginated entities matching the template - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; - } + /// Retrieves entities filtered by template with existence validation. + /// + /// **Contract:** Returns paginated entities that conform to the specified + /// template. + /// Template existence is validated first to ensure meaningful results. + /// + /// @param pageable pagination configuration for large entity sets + /// @param templateIdentifier business identifier of the entity template + /// @return paginated entities matching the template + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public Page getEntitiesByTemplateIdentifier(Pageable pageable, + String templateIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers - public List getEntitiesSummariesByIndentifiers(List identifiers) { - return entityRepository.findByIdentifierIn(identifiers); - } + } - /// Retrieves a specific entity with template and entity validation. - /// - /// **Contract:** Returns the entity identified by both template and entity identifiers. - /// Validates template existence first, then entity existence, ensuring referential integrity. - /// - /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template - /// @return the entity matching both identifiers - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist - @Transactional - public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, - entityIdentifier)); - } + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers + public List getEntitiesSummariesByIndentifiers(List identifiers) { + return entityRepository.findByIdentifierIn(identifiers); + } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Resolves the referenced template (single round-trip — combined - /// existence check and fetch), enforces entity identifier uniqueness within the - /// template scope, then validates entity/property data integrity against the - /// resolved template before persisting. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers - /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist - /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template - /// @throws EntityValidationException when entity, property, or relation data is invalid - @Transactional - public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateForCreation(entity, template); - return entityRepository.save(entity); - } + /// Retrieves a specific entity with template and entity validation. + /// + /// **Contract:** Returns the entity identified by both template and entity + /// identifiers. + /// Validates template existence first, then entity existence, ensuring + /// referential integrity. + /// + /// @param templateIdentifier business identifier of the entity template + /// @param entityIdentifier unique business identifier of the entity within + /// template + /// @return the entity matching both identifiers + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist + @Transactional + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, + String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + } + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't + /// exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier + /// already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is + /// invalid + @Transactional + public Entity createEntity(@Valid Entity entity) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateForCreation(entity, template); + return entityRepository.save(entity); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 8143e6c..bb15baf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -30,67 +30,71 @@ @AllArgsConstructor public class EntityValidationService { - private final EntityRepositoryPort entityRepository; - private final PropertyValidationService propertyValidationService; + private final EntityRepositoryPort entityRepository; + private final PropertyValidationService propertyValidationService; - /// Validates intrinsic entity data integrity and template-driven rules. - /// - /// **Contract:** the caller is responsible for resolving the [EntityTemplate] - /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) - /// and passing it in. This avoids a redundant database round-trip and clarifies - /// the dependency graph of the validation service. - /// - /// @param entity the entity to validate - /// @param template the already-resolved template the entity must conform to - /// @throws EntityValidationException when one or more validation rules are violated - /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateForCreation(Entity entity, EntityTemplate template) { - validateUniqueness(entity); - validateAgainstTemplate(template, entity.properties()); - } - - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. - /// @param template the entity template whose property definitions are used for validation - /// @param properties the list of properties from the entity to validate - private void validateAgainstTemplate(EntityTemplate template, - List properties) { - Violations violations = new Violations(); - List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); - Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() - .filter(p -> p.name() != null) - .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via + /// [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. + /// + /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are + /// violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier + /// exists for the template + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); + } - for (PropertyDefinition definition : definitions) { - Property property = propertiesByName.get(definition.name()); - boolean missing = property == null - || property.value() == null - || (property.value().isBlank()); + /// Validates entity properties against the template's property definitions, + /// enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for + /// validation + /// @param properties the list of properties from the entity to validate + private void validateAgainstTemplate(EntityTemplate template, List properties) { + Violations violations = new Violations(); + List definitions = Optional.ofNullable(template.propertiesDefinitions()) + .orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()) + .stream().filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); - if (missing) { - if (definition.required()) { - violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); - } - continue; - } + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null + || (property.value().isBlank()); - propertyValidationService - .validatePropertyValue(definition, property.value()) - .forEach(violations::add); - } - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); } + continue; + } + + propertyValidationService.validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); } + } - /// Checks for existing entity with same template and identifier to prevent duplicates. - /// @param entity the entity to check for existence - /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - private void validateUniqueness(final Entity entity) { - if (entity.identifier() != null - && entityRepository - .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) - .isPresent()) { - throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); - } + /// Checks for existing entity with same template and identifier to prevent + /// duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and + /// identifier already exists + private void validateUniqueness(final Entity entity) { + if (entity.identifier() != null && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java index 92a3dd6..ddaad98 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -8,28 +8,28 @@ /// validators stay focused on the rule they enforce rather than on string /// concatenation. Not thread-safe; intended for short-lived per-request use. final class Violations { - private final List messages = new ArrayList<>(); - void add(String message) { - messages.add(message); - } - void add(String template, Object... args) { - messages.add(template.formatted(args)); - } - void addIfBlank(String value, String message) { - if (value == null || value.isBlank()) { - messages.add(message); - } + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); } + } - /// Adds a violation prefixed with the indexed collection name, e.g. - /// `Property[2]: Property name is mandatory`. - void addIndexed(String collection, int index, String message) { - messages.add("%s[%d]: %s".formatted(collection, index, message)); - } - boolean isEmpty() { - return messages.isEmpty(); - } - List asList() { - return List.copyOf(messages); - } + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index af23ff7..121145e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -45,132 +45,129 @@ @RequiredArgsConstructor public class EntityGraphService { - private static final int MAX_DEPTH = 10; - - private final EntityRepositoryPort entityRepositoryPort; - private final EntityGraphRepositoryPort entityGraphRepositoryPort; - - /// Builds the relationship graph for an entity starting from its composite key. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) - /// @param includeProperties when true, each graph node carries the entity's full property list - /// @return the root graph node with all resolved relations - /// @throws EntityNotFoundException when no entity matches the given identifiers - @Transactional(readOnly = true) - public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, - boolean includeProperties) { - int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - - Entity rootEntity = entityRepositoryPort - .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - - Map entityMap = entityGraphRepositoryPort - .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); - - EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); - - // One shared visited set per request — each node is fully expanded at most once, - // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. - Set visitedNodeIds = new HashSet<>(); - - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); + private static final int MAX_DEPTH = 10; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + + /// Builds the relationship graph for an entity starting from its composite key. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's + /// full property list + /// @return the root graph node with all resolved relations + /// @throws EntityNotFoundException when no entity matches the given identifiers + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, + int depth, boolean includeProperties) { + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), + rootEntity.identifier()); + + // One shared visited set per request — each node is fully expanded at most + // once, + // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. + Set visitedNodeIds = new HashSet<>(); + + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); + } + + /// Builds a graph node from a pre-loaded entity map (no database calls). + /// + /// [visitedNodeIds] tracks nodes that have already been fully built in this + /// traversal. + /// When a node is encountered again (cycle or shared reference), a stub leaf is + /// returned + /// immediately to cut the recursion — preventing the exponential blowup that + /// arises from + /// inbound scanning re-expanding the same nodes at every depth level. + private EntityGraphNode buildGraphNode(EntityCompositeKey key, + Map entityMap, int remainingDepth, boolean includeProperties, + Set visitedNodeIds) { + Entity entity = entityMap.get(key); + if (entity == null) { + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), + List.of(), List.of(), List.of()); } - /// Builds a graph node from a pre-loaded entity map (no database calls). - /// - /// [visitedNodeIds] tracks nodes that have already been fully built in this traversal. - /// When a node is encountered again (cycle or shared reference), a stub leaf is returned - /// immediately to cut the recursion — preventing the exponential blowup that arises from - /// inbound scanning re-expanding the same nodes at every depth level. - private EntityGraphNode buildGraphNode(EntityCompositeKey key, - Map entityMap, - int remainingDepth, - boolean includeProperties, - Set visitedNodeIds) { - Entity entity = entityMap.get(key); - if (entity == null) { - return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), - List.of(), List.of(), List.of()); - } - - // Guard: return a stub leaf if this node was already fully built in another branch. - // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). - // Properties are still included so data is not silently dropped for shared nodes. - var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); - if (!visitedNodeIds.add(nodeId)) { - List stubProperties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - stubProperties, List.of(), List.of()); - } - - // Depth exhausted — return a leaf with no relations but still carry properties - // so the deepest reachable entities expose their data when include_data=true. - if (remainingDepth <= 0) { - List leafProperties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - leafProperties, List.of(), List.of()); - } - - List outboundRelations = entity.relations().stream() - .map(relation -> new EntityGraphRelation( - relation.name(), - relation.targetEntityIdentifiers().stream() - .map(targetId -> buildGraphNode( - findKeyByIdentifier(targetId, entityMap), - entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) - .toList() - )) - .toList(); - - List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); - - List properties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); + // Guard: return a stub leaf if this node was already fully built in another + // branch. + // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + // Properties are still included so data is not silently dropped for shared + // nodes. + var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); + if (!visitedNodeIds.add(nodeId)) { + List stubProperties = includeProperties ? entity.properties() : List.of(); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + stubProperties, List.of(), List.of()); } - /// Looks up a composite key from the map by identifier alone. - /// Falls back to a synthetic key if no match is found (entity not in graph). - private EntityCompositeKey findKeyByIdentifier(String identifier, Map entityMap) { - return entityMap.keySet().stream() - .filter(k -> k.identifier().equals(identifier)) - .findFirst() - .orElse(new EntityCompositeKey("", identifier)); + // Depth exhausted — return a leaf with no relations but still carry properties + // so the deepest reachable entities expose their data when include_data=true. + if (remainingDepth <= 0) { + List leafProperties = includeProperties ? entity.properties() : List.of(); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + leafProperties, List.of(), List.of()); } - /// Builds incoming relations (where this entity is the target) from the pre-loaded entity map. - /// Passes [visitedNodeIds] through so that source nodes already expanded elsewhere are not - /// re-expanded here, preventing the mutual recursion that causes OOM at high depths. - private List buildRelationsAsTargetFromMap(String targetIdentifier, - Map entityMap, - int remainingDepth, - boolean includeProperties, - Set visitedNodeIds) { - Map> sourcesByRelationName = new HashMap<>(); - - for (Map.Entry entry : entityMap.entrySet()) { - Entity sourceEntity = entry.getValue(); - for (Relation relation : sourceEntity.relations()) { - if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { - sourcesByRelationName - .computeIfAbsent(relation.name(), k -> new ArrayList<>()) - .add(entry.getKey()); - } - } + List outboundRelations = entity.relations().stream() + .map(relation -> new EntityGraphRelation(relation.name(), relation.targetEntityIdentifiers() + .stream().map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) + .toList())) + .toList(); + + List inboundRelations = buildRelationsAsTargetFromMap(entity.identifier(), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); + + List properties = includeProperties ? entity.properties() : List.of(); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + properties, outboundRelations, inboundRelations); + } + + /// Looks up a composite key from the map by identifier alone. + /// Falls back to a synthetic key if no match is found (entity not in graph). + private EntityCompositeKey findKeyByIdentifier(String identifier, + Map entityMap) { + return entityMap.keySet().stream().filter(k -> k.identifier().equals(identifier)).findFirst() + .orElse(new EntityCompositeKey("", identifier)); + } + + /// Builds incoming relations (where this entity is the target) from the + /// pre-loaded entity map. + /// Passes [visitedNodeIds] through so that source nodes already expanded + /// elsewhere are not + /// re-expanded here, preventing the mutual recursion that causes OOM at high + /// depths. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, int remainingDepth, boolean includeProperties, + Set visitedNodeIds) { + Map> sourcesByRelationName = new HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + Entity sourceEntity = entry.getValue(); + for (Relation relation : sourceEntity.relations()) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + sourcesByRelationName.computeIfAbsent(relation.name(), k -> new ArrayList<>()) + .add(entry.getKey()); } - - return sourcesByRelationName.entrySet().stream() - .map(e -> new EntityGraphRelation( - e.getKey(), - e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, - includeProperties, visitedNodeIds)) - .toList() - )) - .toList(); + } } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation(e.getKey(), + e.getValue().stream().map(sourceKey -> buildGraphNode(sourceKey, entityMap, + remainingDepth, includeProperties, visitedNodeIds)).toList())) + .toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index 3528e14..877a084 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -8,6 +8,9 @@ import java.util.function.Function; import java.util.stream.Collectors; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -23,8 +26,6 @@ import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// Domain service orchestrating [EntityTemplate] business operations and lifecycle management. @@ -44,264 +45,272 @@ @RequiredArgsConstructor public class EntityTemplateService { - private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityRepositoryPort entityRepositoryPort; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityRepositoryPort entityRepositoryPort; - /// Retrieves paginated entity templates for management interface display. - /// - /// **Contract:** Returns templates with pagination metadata for efficient UI rendering. - /// Supports sorting and filtering through Spring's Pageable interface for flexible - /// template browsing and administration. - /// - /// @param pageable pagination configuration including page size, number, and sorting - /// @return paginated template results with metadata - public Page getEntityTemplates(Pageable pageable) { - return entityTemplateRepositoryPort.findAll(pageable); - } + /// Retrieves paginated entity templates for management interface display. + /// + /// **Contract:** Returns templates with pagination metadata for efficient UI + /// rendering. + /// Supports sorting and filtering through Spring's Pageable interface for + /// flexible + /// template browsing and administration. + /// + /// @param pageable pagination configuration including page size, number, and + /// sorting + /// @return paginated template results with metadata + public Page getEntityTemplates(Pageable pageable) { + return entityTemplateRepositoryPort.findAll(pageable); + } - /// Retrieves a specific entity template by business identifier. - /// - /// **Contract:** Performs exact match lookup for template identification. - /// Case-sensitive matching ensures precise template resolution for entity operations. - /// - /// @param identifier unique business identifier of the template - /// @return the matching [EntityTemplate] - /// @throws EntityTemplateNotFoundException when template doesn't exist - public EntityTemplate getEntityTemplateByIdentifier(String identifier) { - return entityTemplateRepositoryPort.findByIdentifier(identifier) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", identifier)); - } + /// Retrieves a specific entity template by business identifier. + /// + /// **Contract:** Performs exact match lookup for template identification. + /// Case-sensitive matching ensures precise template resolution for entity + /// operations. + /// + /// @param identifier unique business identifier of the template + /// @return the matching [EntityTemplate] + /// @throws EntityTemplateNotFoundException when template doesn't exist + public EntityTemplate getEntityTemplateByIdentifier(String identifier) { + return entityTemplateRepositoryPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", identifier)); + } - /// Creates and persists a new entity template. - /// - /// **Contract:** Validates the provided `EntityTemplate` and enforces uniqueness - /// constraints on both `identifier` and `name` when present. If validation passes, - /// the template is persisted and the persisted instance (including any generated - /// identifiers) is returned. - /// - /// **Business rules enforced:** - /// - If `identifier` is provided it must not already exist in the system. - /// - If `name` is provided it must not already exist in the system. - /// - Validation of property rules according to their defined constraints. - /// - /// @param entityTemplate validated template to create and persist - /// @return the persisted template with generated identifiers - /// @throws EntityTemplateAlreadyExistsException when identifier already exists - /// @throws EntityTemplateNameAlreadyExistsException when name already exists - @Transactional - public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { - entityTemplateValidationService.validateForCreation(entityTemplate); - return entityTemplateRepositoryPort.save(entityTemplate); - } - - /// Updates an existing entity template using full replacement with smart merging. - /// - /// **Contract:** Replaces the template's scalar fields (identifier, name, description) with the - /// incoming values, while performing an intelligent merge on nested collections - /// (properties and relations). Matching children (by name) preserve their existing UUIDs - /// so the persistence layer treats them as updates rather than delete-and-recreate, - /// avoiding unnecessary orphan removal and re-insertion. - /// - /// **Business rules enforced:** - /// - The target template must already exist (looked up by the path `identifier`). - /// - If the caller changes the identifier, the new value must not collide with another template. - /// - Property and relation definitions are merged by name: - /// - *Matched by name* → existing ID is preserved, other fields are overwritten. - /// - *Not matched* → treated as a new definition (no ID yet). - /// - *Missing from update* → removed (handled downstream by the persistence adapter). - /// - Validation of property rules according to their defined constraints. - /// - /// @param identifier current business identifier of the template to update - /// @param entityTemplate validated template carrying the desired state - /// @return the persisted template after merge, with generated or preserved identifiers - /// @throws EntityTemplateNotFoundException when no template matches `identifier` - /// @throws EntityTemplateAlreadyExistsException when renaming would cause a duplicate - @Transactional - public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTemplate entityTemplate) { - EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); - EntityTemplate mergedTemplate = new EntityTemplate( - existingTemplate.id(), - entityTemplate.identifier(), - entityTemplate.name(), - entityTemplate.description(), - mergePropertyDefinitions(existingTemplate.propertiesDefinitions(), - entityTemplate.propertiesDefinitions()), - mergeRelationDefinitions(existingTemplate.relationsDefinitions(), - entityTemplate.relationsDefinitions()) - ); - entityTemplateValidationService.validateForUpdate(identifier, existingTemplate.name(), existingTemplate, mergedTemplate); - EntityTemplate savedTemplate = entityTemplateRepositoryPort.save(mergedTemplate); - purgeRemovedProperties(identifier, existingTemplate.propertiesDefinitions(), entityTemplate.propertiesDefinitions()); - purgeRemovedRelations(identifier, existingTemplate.relationsDefinitions(), entityTemplate.relationsDefinitions()); - return savedTemplate; - } + /// Creates and persists a new entity template. + /// + /// **Contract:** Validates the provided `EntityTemplate` and enforces + /// uniqueness + /// constraints on both `identifier` and `name` when present. If validation + /// passes, + /// the template is persisted and the persisted instance (including any + /// generated + /// identifiers) is returned. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Validation of property rules according to their defined constraints. + /// + /// @param entityTemplate validated template to create and persist + /// @return the persisted template with generated identifiers + /// @throws EntityTemplateAlreadyExistsException when identifier already exists + /// @throws EntityTemplateNameAlreadyExistsException when name already exists + @Transactional + public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { + entityTemplateValidationService.validateForCreation(entityTemplate); + return entityTemplateRepositoryPort.save(entityTemplate); + } - /// Deletes an entity template by business identifier with existence validation. - /// - /// **Contract:** Validates template existence before deletion to ensure referential - /// integrity. Deletion cascades through persistence layer according to configured - /// relationships. This operation is irreversible once committed. - /// - /// @param identifier unique business identifier of template to delete - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public void deleteEntityTemplate(String identifier) { - entityTemplateValidationService.validateForDeletion(identifier); - entityTemplateRepositoryPort.deleteByIdentifier(identifier); - } + /// Updates an existing entity template using full replacement with smart + /// merging. + /// + /// **Contract:** Replaces the template's scalar fields (identifier, name, + /// description) with the + /// incoming values, while performing an intelligent merge on nested collections + /// (properties and relations). Matching children (by name) preserve their + /// existing UUIDs + /// so the persistence layer treats them as updates rather than + /// delete-and-recreate, + /// avoiding unnecessary orphan removal and re-insertion. + /// + /// **Business rules enforced:** + /// - The target template must already exist (looked up by the path + /// `identifier`). + /// - If the caller changes the identifier, the new value must not collide with + /// another template. + /// - Property and relation definitions are merged by name: + /// - *Matched by name* → existing ID is preserved, other fields are + /// overwritten. + /// - *Not matched* → treated as a new definition (no ID yet). + /// - *Missing from update* → removed (handled downstream by the persistence + /// adapter). + /// - Validation of property rules according to their defined constraints. + /// + /// @param identifier current business identifier of the template to update + /// @param entityTemplate validated template carrying the desired state + /// @return the persisted template after merge, with generated or preserved + /// identifiers + /// @throws EntityTemplateNotFoundException when no template matches + /// `identifier` + /// @throws EntityTemplateAlreadyExistsException when renaming would cause a + /// duplicate + @Transactional + public EntityTemplate updateEntityTemplate(String identifier, + @Valid EntityTemplate entityTemplate) { + EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); + EntityTemplate mergedTemplate = new EntityTemplate(existingTemplate.id(), + entityTemplate.identifier(), entityTemplate.name(), entityTemplate.description(), + mergePropertyDefinitions(existingTemplate.propertiesDefinitions(), + entityTemplate.propertiesDefinitions()), + mergeRelationDefinitions(existingTemplate.relationsDefinitions(), + entityTemplate.relationsDefinitions())); + entityTemplateValidationService.validateForUpdate(identifier, existingTemplate.name(), + existingTemplate, mergedTemplate); + EntityTemplate savedTemplate = entityTemplateRepositoryPort.save(mergedTemplate); + purgeRemovedProperties(identifier, existingTemplate.propertiesDefinitions(), + entityTemplate.propertiesDefinitions()); + purgeRemovedRelations(identifier, existingTemplate.relationsDefinitions(), + entityTemplate.relationsDefinitions()); + return savedTemplate; + } - private List mergePropertyDefinitions( - List existing, - List updated) { + /// Deletes an entity template by business identifier with existence validation. + /// + /// **Contract:** Validates template existence before deletion to ensure + /// referential + /// integrity. Deletion cascades through persistence layer according to + /// configured + /// relationships. This operation is irreversible once committed. + /// + /// @param identifier unique business identifier of template to delete + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public void deleteEntityTemplate(String identifier) { + entityTemplateValidationService.validateForDeletion(identifier); + entityTemplateRepositoryPort.deleteByIdentifier(identifier); + } - if (existing == null) existing = new ArrayList<>(); - if (updated == null) return existing; + private List mergePropertyDefinitions(List existing, + List updated) { - Map existingMap = existing.stream() - .collect(Collectors.toMap(p -> p.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); + if (existing == null) + existing = new ArrayList<>(); + if (updated == null) + return existing; - List result = new ArrayList<>(); + Map existingMap = existing.stream().collect( + Collectors.toMap(p -> p.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); - for (PropertyDefinition prop : updated) { - PropertyDefinition existingProp = existingMap.get(prop.name().toLowerCase(java.util.Locale.ROOT)); - if (existingProp != null) { - result.add(new PropertyDefinition( - existingProp.id(), - prop.name(), - prop.description(), - prop.type(), - prop.required(), - mergePropertyRules(existingProp.rules(), prop.rules()) - )); - } else { - result.add(prop); - } - } + List result = new ArrayList<>(); - return result; + for (PropertyDefinition prop : updated) { + PropertyDefinition existingProp = existingMap + .get(prop.name().toLowerCase(java.util.Locale.ROOT)); + if (existingProp != null) { + result.add(new PropertyDefinition(existingProp.id(), prop.name(), prop.description(), + prop.type(), prop.required(), mergePropertyRules(existingProp.rules(), prop.rules()))); + } else { + result.add(prop); + } } - private PropertyRules mergePropertyRules(PropertyRules existingRules, PropertyRules newRules) { - if (newRules == null) { - return existingRules; - } - if (existingRules == null) { - return newRules; - } + return result; + } - return new PropertyRules( - existingRules.id(), - newRules.format(), - newRules.enumValues(), - newRules.regex(), - newRules.maxLength(), - newRules.minLength(), - newRules.maxValue(), - newRules.minValue() - ); + private PropertyRules mergePropertyRules(PropertyRules existingRules, PropertyRules newRules) { + if (newRules == null) { + return existingRules; + } + if (existingRules == null) { + return newRules; } - private List mergeRelationDefinitions( - List existing, - List updated) { + return new PropertyRules(existingRules.id(), newRules.format(), newRules.enumValues(), + newRules.regex(), newRules.maxLength(), newRules.minLength(), newRules.maxValue(), + newRules.minValue()); + } - if (existing == null) existing = new ArrayList<>(); - if (updated == null) return existing; + private List mergeRelationDefinitions(List existing, + List updated) { - Map existingMap = existing.stream() - .collect(Collectors.toMap(r -> r.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); + if (existing == null) + existing = new ArrayList<>(); + if (updated == null) + return existing; - List result = new ArrayList<>(); + Map existingMap = existing.stream().collect( + Collectors.toMap(r -> r.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); - for (RelationDefinition rel : updated) { - RelationDefinition existingRel = existingMap.get(rel.name().toLowerCase(java.util.Locale.ROOT)); - if (existingRel != null) { - result.add(new RelationDefinition( - existingRel.id(), - rel.name(), - rel.targetTemplateIdentifier(), - rel.required(), - rel.toMany() - )); - } else { - result.add(rel); - } - } + List result = new ArrayList<>(); - return result; + for (RelationDefinition rel : updated) { + RelationDefinition existingRel = existingMap + .get(rel.name().toLowerCase(java.util.Locale.ROOT)); + if (existingRel != null) { + result.add(new RelationDefinition(existingRel.id(), rel.name(), + rel.targetTemplateIdentifier(), rel.required(), rel.toMany())); + } else { + result.add(rel); + } } - /// Computes the names of relation definitions present in [existing] but absent from [updated]. - /// - /// **Business purpose:** Identifies which relations were removed in the - /// PUT request so the linked entity relation values can be purged. - /// - /// @param existing relation definitions currently persisted on the template - /// @param updated relation definitions from the incoming PUT request - /// @return names of relation definitions that were removed (never null, may be empty) - private List identifyDeletedRelationNames( - List existing, - List updated) { - if (existing == null || existing.isEmpty()) { - return List.of(); - } - Set updatedRelationNames = (updated == null ? List.of() : updated) - .stream() - .map(r -> r.name().toLowerCase(Locale.ROOT)) - .collect(Collectors.toSet()); - return existing.stream() - .filter(r -> !updatedRelationNames.contains(r.name().toLowerCase(Locale.ROOT))) - .map(RelationDefinition::name) - .toList(); + return result; + } + + /// Computes the names of relation definitions present in [existing] but absent + /// from [updated]. + /// + /// **Business purpose:** Identifies which relations were removed in the + /// PUT request so the linked entity relation values can be purged. + /// + /// @param existing relation definitions currently persisted on the template + /// @param updated relation definitions from the incoming PUT request + /// @return names of relation definitions that were removed (never null, may be + /// empty) + private List identifyDeletedRelationNames(List existing, + List updated) { + if (existing == null || existing.isEmpty()) { + return List.of(); } + Set updatedRelationNames = (updated == null ? List.of() : updated) + .stream().map(r -> r.name().toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + return existing.stream() + .filter(r -> !updatedRelationNames.contains(r.name().toLowerCase(Locale.ROOT))) + .map(RelationDefinition::name).toList(); + } - /// Computes the names of property definitions present in [existing] but absent from [updated]. - /// - /// **Business purpose:** Identifies which properties were removed in the - /// PUT request so their corresponding entity property values can be purged. - /// - /// @param existing property definitions currently persisted on the template - /// @param updated property definitions from the incoming PUT request - /// @return names of property definitions that were removed (never null, may be empty) - private List identifyDeletedPropertyNames( - List existing, - List updated) { - if (existing == null || existing.isEmpty()) { - return List.of(); - } - Set updatedPropertyNames = (updated == null ? List.of() : updated) - .stream() - .map(p -> p.name().toLowerCase(Locale.ROOT)) - .collect(Collectors.toSet()); - return existing.stream() - .filter(p -> !updatedPropertyNames.contains(p.name().toLowerCase(Locale.ROOT))) - .map(PropertyDefinition::name) - .toList(); + /// Computes the names of property definitions present in [existing] but absent + /// from [updated]. + /// + /// **Business purpose:** Identifies which properties were removed in the + /// PUT request so their corresponding entity property values can be purged. + /// + /// @param existing property definitions currently persisted on the template + /// @param updated property definitions from the incoming PUT request + /// @return names of property definitions that were removed (never null, may be + /// empty) + private List identifyDeletedPropertyNames(List existing, + List updated) { + if (existing == null || existing.isEmpty()) { + return List.of(); } + Set updatedPropertyNames = (updated == null ? List.of() : updated) + .stream().map(p -> p.name().toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + return existing.stream() + .filter(p -> !updatedPropertyNames.contains(p.name().toLowerCase(Locale.ROOT))) + .map(PropertyDefinition::name).toList(); + } - /// Identifies and purges property values from entities whose definitions were removed during a template update. - /// - /// @param templateIdentifier the template's business identifier - /// @param existing property definitions currently persisted on the template - /// @param updated property definitions from the incoming PUT request - private void purgeRemovedProperties(String templateIdentifier, List existing, List updated) { - List removedNames = identifyDeletedPropertyNames(existing, updated); - if (!removedNames.isEmpty()) { - entityRepositoryPort.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, removedNames); - } + /// Identifies and purges property values from entities whose definitions were + /// removed during a template update. + /// + /// @param templateIdentifier the template's business identifier + /// @param existing property definitions currently persisted on the template + /// @param updated property definitions from the incoming PUT request + private void purgeRemovedProperties(String templateIdentifier, List existing, + List updated) { + List removedNames = identifyDeletedPropertyNames(existing, updated); + if (!removedNames.isEmpty()) { + entityRepositoryPort.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, + removedNames); } + } - /// Identifies and purges relation values from entities whose definitions were removed during a template update. - /// - /// @param templateIdentifier the template's business identifier - /// @param existing relation definitions currently persisted on the template - /// @param updated relation definitions from the incoming PUT request - private void purgeRemovedRelations(String templateIdentifier, List existing, List updated) { - List removedNames = identifyDeletedRelationNames(existing, updated); - if (!removedNames.isEmpty()) { - entityRepositoryPort.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, removedNames); - } + /// Identifies and purges relation values from entities whose definitions were + /// removed during a template update. + /// + /// @param templateIdentifier the template's business identifier + /// @param existing relation definitions currently persisted on the template + /// @param updated relation definitions from the incoming PUT request + private void purgeRemovedRelations(String templateIdentifier, List existing, + List updated) { + List removedNames = identifyDeletedRelationNames(existing, updated); + if (!removedNames.isEmpty()) { + entityRepositoryPort.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + removedNames); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 5e6e1e5..4ec24fa 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -31,158 +31,186 @@ @RequiredArgsConstructor public class EntityTemplateValidationService { - private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; - private final PropertyDefinitionValidationService propertyDefinitionValidationService; - private final RelationDefinitionValidationService relationDefinitionValidationService; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyDefinitionValidationService propertyDefinitionValidationService; + private final RelationDefinitionValidationService relationDefinitionValidationService; - /// Validates all business rules before creating a new entity template. - /// - /// **Business rules enforced:** - /// - If `identifier` is provided it must not already exist in the system. - /// - If `name` is provided it must not already exist in the system. - /// - Property rules must be compatible with their declared property type. - /// - Relation names must be unique within the template. - /// - No relation may target the template's own identifier (no self-reference). - /// - All target templates referenced by relations must exist in the system. - /// - /// @param entityTemplate the template candidate to validate - /// @throws EntityTemplateAlreadyExistsException when identifier is already taken - /// @throws EntityTemplateNameAlreadyExistsException when name is already taken - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - /// @throws PropertyNameAlreadyExistsException if duplicate property names are found - /// @throws RelationNameAlreadyExistsException if duplicate relation names are found - /// @throws RelationCannotTargetItselfException when a relation targets the template itself - public void validateForCreation(EntityTemplate entityTemplate) { - validateIdentifierUniqueness(entityTemplate.identifier()); - validateNameUniqueness(entityTemplate.name()); - if (entityTemplate.propertiesDefinitions() != null) { - propertyDefinitionValidationService.validatePropertyNamesUniqueness(entityTemplate.propertiesDefinitions()); - validateTemplateProperties(entityTemplate); - } - if (entityTemplate.relationsDefinitions() != null) { - relationDefinitionValidationService.validateRelationNamesUniqueness(entityTemplate.relationsDefinitions()); - validateTemplateRelations(entityTemplate); - } + /// Validates all business rules before creating a new entity template. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Property rules must be compatible with their declared property type. + /// - Relation names must be unique within the template. + /// - No relation may target the template's own identifier (no self-reference). + /// - All target templates referenced by relations must exist in the system. + /// + /// @param entityTemplate the template candidate to validate + /// @throws EntityTemplateAlreadyExistsException when identifier is already + /// taken + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + /// @throws PropertyNameAlreadyExistsException if duplicate property names are + /// found + /// @throws RelationNameAlreadyExistsException if duplicate relation names are + /// found + /// @throws RelationCannotTargetItselfException when a relation targets the + /// template itself + public void validateForCreation(EntityTemplate entityTemplate) { + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(entityTemplate.name()); + if (entityTemplate.propertiesDefinitions() != null) { + propertyDefinitionValidationService + .validatePropertyNamesUniqueness(entityTemplate.propertiesDefinitions()); + validateTemplateProperties(entityTemplate); } - - /// Validates all business rules before persisting an updated entity template. - /// - /// **Business rules enforced:** - /// - If the identifier changed, the new value must not collide with another template. - /// - If the name changed, the new value must not collide with another template. - /// - Property rules in the merged template must be compatible with their declared type. - /// - Relation names must be unique within the template. - /// - No relation may target the template's own identifier (no self-reference). - /// - All target templates referenced by relations must exist in the system. - /// - Relation target template identifiers cannot be changed after creation. - /// - /// @param currentIdentifier the identifier of the template being replaced - /// @param existingName the current name of the template being replaced - /// @param existingTemplate the current state of the template being replaced - /// @param mergedTemplate the fully-merged template carrying the desired state - /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken - /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - /// @throws PropertyNameAlreadyExistsException if duplicate property names are found - /// @throws RelationNameAlreadyExistsException if duplicate relation names are found - /// @throws RelationTargetTemplateChangeException when a relation target template is changed - /// @throws RelationCannotTargetItselfException when a relation targets the template itself - public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate existingTemplate, EntityTemplate mergedTemplate) { - if (!currentIdentifier.equals(mergedTemplate.identifier())) { - throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); - } - if (!Objects.equals(existingName, mergedTemplate.name())) { - validateNameUniqueness(mergedTemplate.name()); - } - if (mergedTemplate.propertiesDefinitions() != null) { - propertyDefinitionValidationService.validatePropertyNamesUniqueness(mergedTemplate.propertiesDefinitions()); - propertyDefinitionValidationService.validateTypeChanges(existingTemplate.propertiesDefinitions(), mergedTemplate.propertiesDefinitions()); - validateTemplateProperties(mergedTemplate); - } - if (mergedTemplate.relationsDefinitions() != null) { - relationDefinitionValidationService.validateRelationNamesUniqueness(mergedTemplate.relationsDefinitions()); - relationDefinitionValidationService.validateTargetTemplateChanges(existingTemplate.relationsDefinitions(), mergedTemplate.relationsDefinitions()); - validateTemplateRelations(mergedTemplate); - } + if (entityTemplate.relationsDefinitions() != null) { + relationDefinitionValidationService + .validateRelationNamesUniqueness(entityTemplate.relationsDefinitions()); + validateTemplateRelations(entityTemplate); } + } - /// Validates that a template identifier is non-null and refers to an existing template. - /// - /// @param identifier the identifier of the template to delete - /// @throws EntityTemplateNotFoundException when `identifier` is null - /// @throws EntityTemplateNotFoundException when no template matches `identifier` - public void validateForDeletion(String identifier) { - if (identifier == null) { - throw new EntityTemplateNotFoundException("identifier", "null"); - } - validateTemplateExists(identifier); + /// Validates all business rules before persisting an updated entity template. + /// + /// **Business rules enforced:** + /// - If the identifier changed, the new value must not collide with another + /// template. + /// - If the name changed, the new value must not collide with another template. + /// - Property rules in the merged template must be compatible with their + /// declared type. + /// - Relation names must be unique within the template. + /// - No relation may target the template's own identifier (no self-reference). + /// - All target templates referenced by relations must exist in the system. + /// - Relation target template identifiers cannot be changed after creation. + /// + /// @param currentIdentifier the identifier of the template being replaced + /// @param existingName the current name of the template being replaced + /// @param existingTemplate the current state of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is + /// already taken + /// @throws EntityTemplateNameAlreadyExistsException when the new name is + /// already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + /// @throws PropertyNameAlreadyExistsException if duplicate property names are + /// found + /// @throws RelationNameAlreadyExistsException if duplicate relation names are + /// found + /// @throws RelationTargetTemplateChangeException when a relation target + /// template is changed + /// @throws RelationCannotTargetItselfException when a relation targets the + /// template itself + public void validateForUpdate(String currentIdentifier, String existingName, + EntityTemplate existingTemplate, EntityTemplate mergedTemplate) { + if (!currentIdentifier.equals(mergedTemplate.identifier())) { + throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); + } + if (!Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(mergedTemplate.name()); + } + if (mergedTemplate.propertiesDefinitions() != null) { + propertyDefinitionValidationService + .validatePropertyNamesUniqueness(mergedTemplate.propertiesDefinitions()); + propertyDefinitionValidationService.validateTypeChanges( + existingTemplate.propertiesDefinitions(), mergedTemplate.propertiesDefinitions()); + validateTemplateProperties(mergedTemplate); } + if (mergedTemplate.relationsDefinitions() != null) { + relationDefinitionValidationService + .validateRelationNamesUniqueness(mergedTemplate.relationsDefinitions()); + relationDefinitionValidationService.validateTargetTemplateChanges( + existingTemplate.relationsDefinitions(), mergedTemplate.relationsDefinitions()); + validateTemplateRelations(mergedTemplate); + } + } - /// Checks that the entity template exists. - /// - /// @param identifier the identifier to check for existence - /// @throws EntityTemplateNotFoundException when no template matches `identifier` - public void validateTemplateExists(String identifier) { - if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { - throw new EntityTemplateNotFoundException("identifier", identifier); - } + /// Validates that a template identifier is non-null and refers to an existing + /// template. + /// + /// @param identifier the identifier of the template to delete + /// @throws EntityTemplateNotFoundException when `identifier` is null + /// @throws EntityTemplateNotFoundException when no template matches + /// `identifier` + public void validateForDeletion(String identifier) { + if (identifier == null) { + throw new EntityTemplateNotFoundException("identifier", "null"); } + validateTemplateExists(identifier); + } - /// Checks that no other template already uses the given identifier. - /// - /// @param identifier the identifier to check for uniqueness - /// @throws EntityTemplateAlreadyExistsException when identifier is already taken - public void validateIdentifierUniqueness(String identifier) { - if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { - throw new EntityTemplateAlreadyExistsException(identifier); - } + /// Checks that the entity template exists. + /// + /// @param identifier the identifier to check for existence + /// @throws EntityTemplateNotFoundException when no template matches + /// `identifier` + public void validateTemplateExists(String identifier) { + if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException("identifier", identifier); } + } - /// Checks that no other template already uses the given name. - /// - /// @param name the name to check for uniqueness - /// @throws EntityTemplateNameAlreadyExistsException when name is already taken - public void validateNameUniqueness(String name) { - if (entityTemplateRepositoryPort.existsByName(name)) { - throw new EntityTemplateNameAlreadyExistsException(name); - } + /// Checks that no other template already uses the given identifier. + /// + /// @param identifier the identifier to check for uniqueness + /// @throws EntityTemplateAlreadyExistsException when identifier is already + /// taken + public void validateIdentifierUniqueness(String identifier) { + if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); } + } - /// Validates all property definitions within the template for structural and - /// referential integrity. - /// - /// **Contract:** Enforces properties business rules - /// - Property rules integrity: all rules referenced by properties must - /// be valid and coherent based on the property's type - /// - /// **Precondition:** propertiesDefinitions must not be null - /// - /// @param entityTemplate the template containing properties to validate - /// @throws PropertyDefinitionRulesConflictException when rules violate business - /// logic - private void validateTemplateProperties(EntityTemplate entityTemplate) { - for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { - propertyDefinitionValidationService.validatePropertyDefinitionRules(property); - } + /// Checks that no other template already uses the given name. + /// + /// @param name the name to check for uniqueness + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); } + } - /// Validates all relation definitions within the template for structural and - /// referential integrity. - /// - /// **Contract:** Enforces relation business rules - /// - No relation may target the template's own identifier - /// - Referential integrity: all target templates referenced by relations must - /// exist in the system - /// - /// **Precondition:** relationsDefinitions must not be null - /// - /// @param entityTemplate the template containing relations to validate - /// @throws TargetTemplateNotFoundException if any referenced target template - /// @throws RelationCannotTargetItselfException if a relation targets the template itself - /// doesn't exist - private void validateTemplateRelations(EntityTemplate entityTemplate) { - relationDefinitionValidationService.validateRelationNoSelfReference(entityTemplate.identifier(), entityTemplate.relationsDefinitions()); - relationDefinitionValidationService.validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); + /// Validates all property definitions within the template for structural and + /// referential integrity. + /// + /// **Contract:** Enforces properties business rules + /// - Property rules integrity: all rules referenced by properties must + /// be valid and coherent based on the property's type + /// + /// **Precondition:** propertiesDefinitions must not be null + /// + /// @param entityTemplate the template containing properties to validate + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// logic + private void validateTemplateProperties(EntityTemplate entityTemplate) { + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); } + } + + /// Validates all relation definitions within the template for structural and + /// referential integrity. + /// + /// **Contract:** Enforces relation business rules + /// - No relation may target the template's own identifier + /// - Referential integrity: all target templates referenced by relations must + /// exist in the system + /// + /// **Precondition:** relationsDefinitions must not be null + /// + /// @param entityTemplate the template containing relations to validate + /// @throws TargetTemplateNotFoundException if any referenced target template + /// @throws RelationCannotTargetItselfException if a relation targets the + /// template itself + /// doesn't exist + private void validateTemplateRelations(EntityTemplate entityTemplate) { + relationDefinitionValidationService.validateRelationNoSelfReference(entityTemplate.identifier(), + entityTemplate.relationsDefinitions()); + relationDefinitionValidationService + .validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 209f285..d61a4bd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -44,305 +44,264 @@ @RequiredArgsConstructor public class PropertyDefinitionValidationService { - private final PropertyRegexValidationService propertyRegexValidationService; + private final PropertyRegexValidationService propertyRegexValidationService; - // Rule name constants - public static final String REGEX = "regex"; - public static final String LENGTH = "length"; - public static final String VALUE = "value"; - public static final String FORMAT = "format"; - public static final String ENUM_VALUES = "enum_values"; - public static final String MAX_LENGTH = "max_length"; - public static final String MIN_LENGTH = "min_length"; - public static final String MAX_VALUE = "max_value"; - public static final String MIN_VALUE = "min_value"; + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; - /// Validates that all property names are unique within a template. - /// - /// **Contract:** Enforces the invariant that property names must be unique. Used - /// during template creation and updates to prevent duplicate property - /// definitions. - /// - /// @param properties the list of property definitions to validate - /// @throws PropertyNameAlreadyExistsException if duplicate property names - /// are found - public void validatePropertyNamesUniqueness(List properties) { - Set names = new HashSet<>(); - for (PropertyDefinition property : properties) { - if (property.name() != null) { - String normalizedName = property.name().toLowerCase(Locale.ROOT); - if (!names.add(normalizedName)) { - throw new PropertyNameAlreadyExistsException(property.name()); - } - } + /// Validates that all property names are unique within a template. + /// + /// **Contract:** Enforces the invariant that property names must be unique. + /// Used + /// during template creation and updates to prevent duplicate property + /// definitions. + /// + /// @param properties the list of property definitions to validate + /// @throws PropertyNameAlreadyExistsException if duplicate property names + /// are found + public void validatePropertyNamesUniqueness(List properties) { + Set names = new HashSet<>(); + for (PropertyDefinition property : properties) { + if (property.name() != null) { + String normalizedName = property.name().toLowerCase(Locale.ROOT); + if (!names.add(normalizedName)) { + throw new PropertyNameAlreadyExistsException(property.name()); } + } } + } - /// Validates that property types are not changed on existing properties. - /// - /// **Contract:** Enforces the invariant that property types cannot be modified - /// after initial creation. Any attempt to change a property type is forbidden. - /// Users must delete and recreate the property if they need to change its type. - /// - /// @param existingProperties the existing property definitions - /// @param incomingProperties the new/updated property definitions - /// @throws PropertyTypeChangeException if any property type change is attempted - public void validateTypeChanges(List existingProperties, List incomingProperties) { - if (existingProperties == null || existingProperties.isEmpty() || - incomingProperties == null || incomingProperties.isEmpty()) { - return; - } - Map updatedMap = incomingProperties.stream() - .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); + /// Validates that property types are not changed on existing properties. + /// + /// **Contract:** Enforces the invariant that property types cannot be modified + /// after initial creation. Any attempt to change a property type is forbidden. + /// Users must delete and recreate the property if they need to change its type. + /// + /// @param existingProperties the existing property definitions + /// @param incomingProperties the new/updated property definitions + /// @throws PropertyTypeChangeException if any property type change is attempted + public void validateTypeChanges(List existingProperties, + List incomingProperties) { + if (existingProperties == null || existingProperties.isEmpty() || incomingProperties == null + || incomingProperties.isEmpty()) { + return; + } + Map updatedMap = incomingProperties.stream() + .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); - for (PropertyDefinition existing : existingProperties) { - PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); - boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); + for (PropertyDefinition existing : existingProperties) { + PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); + boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); - if (propertyTypeChanged) { - throw new PropertyTypeChangeException( - existing.name(), - existing.type(), - updated.type()); - } - } + if (propertyTypeChanged) { + throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type()); + } } + } - /// Validates property rules are compatible with the property's data type. - /// - /// **Contract:** Performs comprehensive validation including: - /// - Rule type compatibility with property type - /// - Numeric constraint ordering (min ≤ max) - /// - Boolean properties reject all rules - /// - /// @param propertyDefinition the property definition containing type and rules - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { - if (propertyDefinition.rules() == null) { - return; - } + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } - PropertyRules rules = propertyDefinition.rules(); - PropertyType type = propertyDefinition.type(); + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); - switch (type) { - case STRING: - validateStringPropertyRules(propertyDefinition.name(), rules); - break; - case NUMBER: - validateNumberPropertyRules(propertyDefinition.name(), rules); - break; - case BOOLEAN: - validateBooleanPropertyRules(propertyDefinition.name(), rules); - break; - default: - throw new IllegalArgumentException("Unknown property type: " + type); - } + switch (type) { + case STRING : + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER : + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN : + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default : + throw new IllegalArgumentException("Unknown property type: " + type); } + } - /// Validates rules for STRING property type. - /// - /// **Allowed rules:** format, enum_values, regex, max_length, min_length - /// **Rejected rules:** max_value, min_value (numeric) - /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; - /// enum_values is also mutually exclusive with max_length and min_length - /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints - private void validateStringPropertyRules(String propertyName, PropertyRules rules) { - validateStringIncompatibleRules(propertyName, rules); - validateStringConstraints(propertyName, rules); + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually + /// exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate + /// any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); - // Validate regex pattern is valid - if (rules.regex() != null && !rules.regex().isBlank()) { - propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); - } + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); } + } - /// Validates numeric constraints for STRING property rules. - /// - /// **Constraints enforced:** - /// - min_length must be non-negative (≥ 0) - /// - max_length must be positive (> 0) - /// - min_length must be less than or equal to max_length - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any constraint is violated - private void validateStringConstraints(String propertyName, PropertyRules rules) { - // Validate min_length is non-negative - if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE - ); - } - // Validate max_length is not zero or negative - if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MAX_LENGTH_POSITIVE - ); - } - // Validate min_length is below or equal to max_length - if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - minMaxConstraintViolated(LENGTH) - ); - } + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is + /// violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE); } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null + && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + minMaxConstraintViolated(LENGTH)); + } + } - /// Validates rule compatibility and mutual exclusivity for STRING property rules. - /// - /// **Incompatibility rules enforced:** - /// - Numeric rules (max_value, min_value) are not allowed for STRING type - /// - format, regex, and enum_values are mutually exclusive - /// - enum_values and length constraints (max_length, min_length) are mutually exclusive - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present - private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { - // Reject numeric rules for STRING type - if (rules.maxValue() != null || rules.minValue() != null) { - String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) - ); - } - - // format, regex, and enum_values are incompatible with each other - if (rules.format() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, ENUM_VALUES) - ); - } - if (rules.format() != null && rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, REGEX) - ); - } - if (rules.regex() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(REGEX, ENUM_VALUES) - ); - } + /// Validates rule compatibility and mutual exclusivity for STRING property + /// rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually + /// exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are + /// both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName)); + } - // enum_values and length constraints are incompatible with each other - if (rules.enumValues() != null && rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) - ); - } - if (rules.enumValues() != null && rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) - ); - } + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES)); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX)); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES)); + } + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH)); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH)); } - /// Validates rules for NUMBER property type. - /// - /// **Allowed rules:** max_value, min_value - /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) - /// **Constraints:** min_value ≤ max_value - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when string rules are present - /// or min/max value constraints are violated - private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) - ); - } + } - if (rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) - ); - } + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length + /// (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are + /// present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name())); + } - if (rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) - ); - } + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name())); + } - if (rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name())); + } - if (rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name())); + } - if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - minMaxConstraintViolated(VALUE) - ); - } + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name())); + } + + if (rules.minValue() != null && rules.maxValue() != null + && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + minMaxConstraintViolated(VALUE)); } + } - /// Validates rules for BOOLEAN property type. - /// - /// **Allowed rules:** None - /// **Rejected rules:** All rules must be null or empty - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN - private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null || - rules.enumValues() != null || - rules.regex() != null || - rules.maxLength() != null || - rules.minLength() != null || - rules.maxValue() != null || - rules.minValue() != null) { + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for + /// BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || rules.enumValues() != null || rules.regex() != null + || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null + || rules.minValue() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.BOOLEAN, - PROPERTY_RULES_BOOLEAN_NOT_ALLOWED - ); - } + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java index 2d70bc1..ee4aca9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java @@ -26,301 +26,312 @@ @Service public class PropertyRegexValidationService { - // Static and thread-safe regex validator executor - private static final ExecutorService VALIDATION_EXECUTOR = Executors.newFixedThreadPool( - Runtime.getRuntime().availableProcessors(), - runnable -> { - Thread thread = new Thread(runnable, "regex-validator-thread"); - thread.setDaemon(true); - return thread; - } - ); - private static final int MAX_REGEX_LENGTH = 1000; - private static final int VALIDATION_TIMEOUT_MS = 30; - // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns - private static final String STRESS_PROBE = "a".repeat(50) + "!"; + // Static and thread-safe regex validator executor + private static final ExecutorService VALIDATION_EXECUTOR = Executors + .newFixedThreadPool(Runtime.getRuntime().availableProcessors(), runnable -> { + Thread thread = new Thread(runnable, "regex-validator-thread"); + thread.setDaemon(true); + return thread; + }); + private static final int MAX_REGEX_LENGTH = 1000; + private static final int VALIDATION_TIMEOUT_MS = 30; + // Validation ReDoS probe string designed to trigger backtracking in vulnerable + // patterns + private static final String STRESS_PROBE = "a".repeat(50) + "!"; - /// Validates the user-provided regex pattern against ReDoS and injection risks. - /// - /// **Security checks:** - /// 1. Rejects patterns exceeding 1,000 characters. - /// 2. Rejects known dangerous regex patterns. - /// 3. Ensures the pattern is valid Java regex. - /// 4. Detects ReDoS by executing pattern matching within 10ms timeout. - /// - /// @param propertyName name of the property (for error reporting) - /// @param regexPattern the regex pattern to validate - /// @throws PropertyDefinitionRulesConflictException if any security check fails - public void validateRegexPattern(String propertyName, String regexPattern) { - if (regexPattern.length() > MAX_REGEX_LENGTH) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); - } + /// Validates the user-provided regex pattern against ReDoS and injection risks. + /// + /// **Security checks:** + /// 1. Rejects patterns exceeding 1,000 characters. + /// 2. Rejects known dangerous regex patterns. + /// 3. Ensures the pattern is valid Java regex. + /// 4. Detects ReDoS by executing pattern matching within 10ms timeout. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyDefinitionRulesConflictException if any security check fails + public void validateRegexPattern(String propertyName, String regexPattern) { + if (regexPattern.length() > MAX_REGEX_LENGTH) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); + } - if (containsDangerousPatterns(regexPattern)) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern contains potentially unsafe constructs"); - } + if (containsDangerousPatterns(regexPattern)) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern contains potentially unsafe constructs"); + } - Pattern compiledRegexPattern; - try { - compiledRegexPattern = Pattern.compile(regexPattern); - } catch (PatternSyntaxException e) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage()); - } + Pattern compiledRegexPattern; + try { + compiledRegexPattern = Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Invalid regex pattern: " + e.getMessage()); + } - validatePatternWithTimeout(propertyName, compiledRegexPattern); - } + validatePatternWithTimeout(propertyName, compiledRegexPattern); + } - /// Validates pattern matching with a timeout to detect ReDoS (Regular Expression Denial of Service) vulnerabilities. - /// - /// Executes a pattern match against a stress probe within a 10 ms timeout using a shared, bounded executor - /// If the pattern takes longer than the timeout, it is rejected as potentially vulnerable to catastrophic backtracking. - /// - /// @param propertyName name of the property (for error reporting) - /// @param pattern the compiled pattern to test - /// @throws PropertyDefinitionRulesConflictException if the pattern times out or validation fails - private void validatePatternWithTimeout(String propertyName, Pattern pattern) { - Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); - try { - future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); - } catch (TimeoutException _) { - future.cancel(true); - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern validation was interrupted"); - } catch (ExecutionException e) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex validation failed: " + e.getCause().getMessage()); - } - } + /// Validates pattern matching with a timeout to detect ReDoS (Regular + /// Expression Denial of Service) vulnerabilities. + /// + /// Executes a pattern match against a stress probe within a 10 ms timeout using + /// a shared, bounded executor + /// If the pattern takes longer than the timeout, it is rejected as potentially + /// vulnerable to catastrophic backtracking. + /// + /// @param propertyName name of the property (for error reporting) + /// @param pattern the compiled pattern to test + /// @throws PropertyDefinitionRulesConflictException if the pattern times out or + /// validation fails + private void validatePatternWithTimeout(String propertyName, Pattern pattern) { + Future future = VALIDATION_EXECUTOR + .submit(() -> pattern.matcher(STRESS_PROBE).matches()); + try { + future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException _) { + future.cancel(true); + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern validation was interrupted"); + } catch (ExecutionException e) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex validation failed: " + e.getCause().getMessage()); + } + } - /// Checks for known dangerous regex constructs using static string analysis. - /// - /// **Patterns detected:** - /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` - /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) - /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` - /// - /// **Implementation:** Uses static string analysis without regex matching - /// to avoid ReDoS vulnerabilities in the validator itself. - /// - /// @param pattern the raw regex string to analyse - /// @return `true` if potentially dangerous constructs are detected - private boolean containsDangerousPatterns(String pattern) { - return hasNestedQuantifiers(pattern) || - hasQuantifiedAlternation(pattern) || - hasLargeRepetitionBounds(pattern) || - hasLookaroundsWithQuantifiers(pattern); - } + /// Checks for known dangerous regex constructs using static string analysis. + /// + /// **Patterns detected:** + /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` + /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) + /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` + /// + /// **Implementation:** Uses static string analysis without regex matching + /// to avoid ReDoS vulnerabilities in the validator itself. + /// + /// @param pattern the raw regex string to analyse + /// @return `true` if potentially dangerous constructs are detected + private boolean containsDangerousPatterns(String pattern) { + return hasNestedQuantifiers(pattern) || hasQuantifiedAlternation(pattern) + || hasLargeRepetitionBounds(pattern) || hasLookaroundsWithQuantifiers(pattern); + } - /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if nested quantifiers are found - private boolean hasNestedQuantifiers(String pattern) { - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { - return true; - } - } - return false; - } + /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if nested quantifiers are found + private boolean hasNestedQuantifiers(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' + && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { + return true; + } + } + return false; + } - /// Checks if a group starting at index i matches the quantified pattern criteria. - /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), - /// and the group content must match the provided test. - /// - /// @param pattern the regex pattern string - /// @param groupStartIndex the index of the opening parenthesis - /// @param test the test to apply to group content - /// @return true if the group matches the criteria - private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { - int closeIndex = findMatchingCloseParenthesis(pattern, groupStartIndex); - if (closeIndex == -1 || closeIndex + 1 >= pattern.length()) { - return false; - } + /// Checks if a group starting at index i matches the quantified pattern + /// criteria. + /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), + /// and the group content must match the provided test. + /// + /// @param pattern the regex pattern string + /// @param groupStartIndex the index of the opening parenthesis + /// @param test the test to apply to group content + /// @return true if the group matches the criteria + private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, + Predicate test) { + int closeIndex = findMatchingCloseParenthesis(pattern, groupStartIndex); + if (closeIndex == -1 || closeIndex + 1 >= pattern.length()) { + return false; + } - char nextChar = pattern.charAt(closeIndex + 1); - if (!isQuantifier(nextChar)) { - return false; - } + char nextChar = pattern.charAt(closeIndex + 1); + if (!isQuantifier(nextChar)) { + return false; + } - String groupContent = pattern.substring(groupStartIndex + 1, closeIndex); - return test.test(groupContent); - } + String groupContent = pattern.substring(groupStartIndex + 1, closeIndex); + return test.test(groupContent); + } - /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if quantified alternation is found - private boolean hasQuantifiedAlternation(String pattern) { - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { - return true; - } - } - return false; - } + /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if quantified alternation is found + private boolean hasQuantifiedAlternation(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' + && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { + return true; + } + } + return false; + } - /// Detects repetition bounds with excessively large upper limits like `{5,9999}`. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if large repetition bounds are found - private boolean hasLargeRepetitionBounds(String pattern) { - int i = 0; - while (i < pattern.length()) { - if (pattern.charAt(i) == '{') { - if (isLargeRepetitionBound(pattern, i)) { - return true; - } - int closeIndex = pattern.indexOf('}', i); - i = closeIndex != -1 ? closeIndex + 1 : i + 1; - } else { - i++; - } - } - return false; - } + /// Detects repetition bounds with excessively large upper limits like + /// `{5,9999}`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if large repetition bounds are found + private boolean hasLargeRepetitionBounds(String pattern) { + int i = 0; + while (i < pattern.length()) { + if (pattern.charAt(i) == '{') { + if (isLargeRepetitionBound(pattern, i)) { + return true; + } + int closeIndex = pattern.indexOf('}', i); + i = closeIndex != -1 ? closeIndex + 1 : i + 1; + } else { + i++; + } + } + return false; + } - /// Checks if the repetition bound starting at position i exceeds the safe limit. - /// - /// @param pattern the regex pattern string - /// @param startIndex the index of the opening brace - /// @return true if the upper bound is greater than 1000 - private boolean isLargeRepetitionBound(String pattern, int startIndex) { - int closeIndex = pattern.indexOf('}', startIndex); - if (closeIndex == -1) { - return false; - } + /// Checks if the repetition bound starting at position i exceeds the safe + /// limit. + /// + /// @param pattern the regex pattern string + /// @param startIndex the index of the opening brace + /// @return true if the upper bound is greater than 1000 + private boolean isLargeRepetitionBound(String pattern, int startIndex) { + int closeIndex = pattern.indexOf('}', startIndex); + if (closeIndex == -1) { + return false; + } - String bounds = pattern.substring(startIndex + 1, closeIndex); - return hasExcessiveUpperBound(bounds); - } + String bounds = pattern.substring(startIndex + 1, closeIndex); + return hasExcessiveUpperBound(bounds); + } - /// Parses a repetition bound string and checks if the upper limit exceeds 1000. - /// - /// @param bounds the bounds string (e.g., "5,9999" or "1,100") - /// @return true if upper bound is greater than 1000 - private boolean hasExcessiveUpperBound(String bounds) { - if (!bounds.contains(",")) { - return false; - } + /// Parses a repetition bound string and checks if the upper limit exceeds 1000. + /// + /// @param bounds the bounds string (e.g., "5,9999" or "1,100") + /// @return true if upper bound is greater than 1000 + private boolean hasExcessiveUpperBound(String bounds) { + if (!bounds.contains(",")) { + return false; + } - String[] parts = bounds.split(","); - if (parts.length != 2 || parts[1].trim().isEmpty()) { - return false; - } + String[] parts = bounds.split(","); + if (parts.length != 2 || parts[1].trim().isEmpty()) { + return false; + } - try { - int upper = Integer.parseInt(parts[1].trim()); - return upper > 1000; - } catch (NumberFormatException _) { - return false; - } - } + try { + int upper = Integer.parseInt(parts[1].trim()); + return upper > 1000; + } catch (NumberFormatException _) { + return false; + } + } - /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. - /// These can amplify backtracking behavior and pose ReDoS risks. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if lookarounds with quantifiers are found - private boolean hasLookaroundsWithQuantifiers(String pattern) { - for (int i = 0; i < pattern.length() - 3; i++) { - if (isLookaroundAt(pattern, i)) { - int closeIndex = findMatchingCloseParenthesis(pattern, i); - if (closeIndex != -1) { - String lookaroundContent = pattern.substring(i, closeIndex + 1); - if (containsQuantifier(lookaroundContent)) { - return true; - } - } - } - } - return false; - } + /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. + /// These can amplify backtracking behavior and pose ReDoS risks. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if lookarounds with quantifiers are found + private boolean hasLookaroundsWithQuantifiers(String pattern) { + for (int i = 0; i < pattern.length() - 3; i++) { + if (isLookaroundAt(pattern, i)) { + int closeIndex = findMatchingCloseParenthesis(pattern, i); + if (closeIndex != -1) { + String lookaroundContent = pattern.substring(i, closeIndex + 1); + if (containsQuantifier(lookaroundContent)) { + return true; + } + } + } + } + return false; + } - /// Checks if position i in pattern is the start of a lookaround construct. - /// Lookarounds are: `(?=...)`, `(?!...)`, `(?<=...)`, `(? relations) { - Set names = new HashSet<>(); - relations.stream() - .map(RelationDefinition::name) - .filter(Objects::nonNull) - .map(name -> name.toLowerCase(Locale.ROOT)) - .filter(name -> !names.add(name)) - .findFirst() - .ifPresent(name -> { - throw new RelationNameAlreadyExistsException(name); - }); - } + /// Validates that all relation names are unique within a template. + /// + /// @param relations the list of relation definitions to validate + /// @throws RelationNameAlreadyExistsException if duplicate relation names + /// are found + public void validateRelationNamesUniqueness(List relations) { + Set names = new HashSet<>(); + relations.stream().map(RelationDefinition::name).filter(Objects::nonNull) + .map(name -> name.toLowerCase(Locale.ROOT)).filter(name -> !names.add(name)).findFirst() + .ifPresent(name -> { + throw new RelationNameAlreadyExistsException(name); + }); + } - /// Validates that all target templates exist for the given relations. - /// - /// **Contract:** Ensures referential integrity by verifying that every - /// target template referenced by a relation exists in the system. - /// - /// @param relations the list of relation definitions to validate - /// @throws TargetTemplateNotFoundException if any referenced target template - /// doesn't exist or is null - public void validateTargetTemplatesExist(List relations) { - for (RelationDefinition relation : relations) { - String targetIdentifier = relation.targetTemplateIdentifier(); - if (targetIdentifier == null || !entityTemplateRepositoryPort.existsByIdentifier(targetIdentifier)) { - throw new TargetTemplateNotFoundException(targetIdentifier); - } - } + /// Validates that all target templates exist for the given relations. + /// + /// **Contract:** Ensures referential integrity by verifying that every + /// target template referenced by a relation exists in the system. + /// + /// @param relations the list of relation definitions to validate + /// @throws TargetTemplateNotFoundException if any referenced target template + /// doesn't exist or is null + public void validateTargetTemplatesExist(List relations) { + for (RelationDefinition relation : relations) { + String targetIdentifier = relation.targetTemplateIdentifier(); + if (targetIdentifier == null + || !entityTemplateRepositoryPort.existsByIdentifier(targetIdentifier)) { + throw new TargetTemplateNotFoundException(targetIdentifier); + } } + } - /// Validates that no relation definition targets itself (the template that owns it). - /// - /// @param templateIdentifier the identifier of the template being created or updated - /// @param relations the list of relation definitions to check - /// @throws RelationCannotTargetItselfException if any relation's target template equals the template's own identifier - public void validateRelationNoSelfReference(String templateIdentifier, List relations) { - if (templateIdentifier == null || relations == null || relations.isEmpty()) { - return; - } - relations.stream() - .filter(r -> templateIdentifier.equals(r.targetTemplateIdentifier())) - .findFirst() - .ifPresent(r -> { - throw new RelationCannotTargetItselfException(r.name(), templateIdentifier); - }); + /// Validates that no relation definition targets itself (the template that owns + /// it). + /// + /// @param templateIdentifier the identifier of the template being created or + /// updated + /// @param relations the list of relation definitions to check + /// @throws RelationCannotTargetItselfException if any relation's target + /// template equals the template's own identifier + public void validateRelationNoSelfReference(String templateIdentifier, + List relations) { + if (templateIdentifier == null || relations == null || relations.isEmpty()) { + return; } + relations.stream().filter(r -> templateIdentifier.equals(r.targetTemplateIdentifier())) + .findFirst().ifPresent(r -> { + throw new RelationCannotTargetItselfException(r.name(), templateIdentifier); + }); + } - /// Validates that target template identifiers are not changed on existing relations. - /// - /// **Contract:** Enforces the invariant that relation target templates cannot be - /// modified after initial creation. Any attempt to change a target template identifier - /// is forbidden, as existing entity relation values would point to the wrong template type. - /// - /// @param existingRelations the existing relation definitions (from the persisted template) - /// @param incomingRelations the new/updated relation definitions - /// @throws RelationTargetTemplateChangeException if any relation target template change is attempted - public void validateTargetTemplateChanges(List existingRelations, List incomingRelations) { - if (existingRelations == null || existingRelations.isEmpty() || - incomingRelations == null || incomingRelations.isEmpty()) { - return; - } + /// Validates that target template identifiers are not changed on existing + /// relations. + /// + /// **Contract:** Enforces the invariant that relation target templates cannot + /// be + /// modified after initial creation. Any attempt to change a target template + /// identifier + /// is forbidden, as existing entity relation values would point to the wrong + /// template type. + /// + /// @param existingRelations the existing relation definitions (from the + /// persisted template) + /// @param incomingRelations the new/updated relation definitions + /// @throws RelationTargetTemplateChangeException if any relation target + /// template change is attempted + public void validateTargetTemplateChanges(List existingRelations, + List incomingRelations) { + if (existingRelations == null || existingRelations.isEmpty() || incomingRelations == null + || incomingRelations.isEmpty()) { + return; + } - Map incomingMap = incomingRelations.stream() - .collect(Collectors.toMap(r -> r.name().toLowerCase(Locale.ROOT), Function.identity())); + Map incomingMap = incomingRelations.stream() + .collect(Collectors.toMap(r -> r.name().toLowerCase(Locale.ROOT), Function.identity())); - for (RelationDefinition existing : existingRelations) { - RelationDefinition incoming = incomingMap.get(existing.name().toLowerCase(Locale.ROOT)); - boolean targetChanged = incoming != null && - !Objects.equals(existing.targetTemplateIdentifier(), incoming.targetTemplateIdentifier()); + for (RelationDefinition existing : existingRelations) { + RelationDefinition incoming = incomingMap.get(existing.name().toLowerCase(Locale.ROOT)); + boolean targetChanged = incoming != null && !Objects + .equals(existing.targetTemplateIdentifier(), incoming.targetTemplateIdentifier()); - if (targetChanged) { - throw new RelationTargetTemplateChangeException( - existing.name(), - existing.targetTemplateIdentifier(), - incoming.targetTemplateIdentifier()); - } - } + if (targetChanged) { + throw new RelationTargetTemplateChangeException(existing.name(), + existing.targetTemplateIdentifier(), incoming.targetTemplateIdentifier()); + } } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 604891c..1fa6a61 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -24,71 +24,77 @@ import com.decathlon.idp_core.domain.model.enums.PropertyType; /** - * Domain service validating entity property values against template definitions. + * Domain service validating entity property values against template + * definitions. */ @Service public class PropertyValidationService { - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); - private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); - - /// Cache of compiled regex patterns keyed by their source string. - /// Avoids recompiling the same pattern on every property validation call. - private final Map patternCache = new ConcurrentHashMap<>(); - - /** - * Validates a concrete property value against its property definition. - * The value's runtime Java type is checked first against the expected - * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, - * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is - * normalized to a string and the type-specific rules are evaluated. - * - * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value preserving its original JSON type - * @return list of violations for this value; empty when valid - */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { - return switch (propertyDefinition.type()) { - case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); - }; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + + /** + * Validates a concrete property value against its property definition. The + * value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, BOOLEAN ⇒ + * {@link Boolean}). When the type matches, the value is normalized to a string + * and the type-specific rules are evaluated. + * + * @param propertyDefinition + * property definition with expected type and optional rules + * @param rawValue + * raw property value preserving its original JSON type + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, + Object rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + private List validateStringPropertyValue(String propertyName, Object rawValue, + PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); } + if (rules == null) { + return List.of(); + } - private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { - if (!(rawValue instanceof String stringValue)) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); - } + var violations = new ArrayList(); - if (rules == null) { - return List.of(); - } - - var violations = new ArrayList(); - - if (rules.minLength() != null && stringValue.length() < rules.minLength()) { - violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); - } - if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { - violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); - } - if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { - violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); - } - if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { - violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); - } - if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { - violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); - } - - return List.copyOf(violations); + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile) + .matcher(stringValue).matches()) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() && rules.enumValues().stream() + .noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } - private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; switch (rawValue) { case Number number -> parsedValue = new BigDecimal(number.toString()); @@ -120,21 +126,21 @@ private List validateNumberPropertyValue(String propertyName, Object raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, Object rawValue) { - if (rawValue instanceof Boolean) { - return List.of(); - } - if (rawValue instanceof String string - && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { - return List.of(); - } - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); } - - private boolean matchesFormat(PropertyFormat format, String value) { - return switch (format) { - case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); - case URL -> URL_PATTERN.matcher(value).matches(); - }; + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { + return List.of(); } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index d71c328..6c516c0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -6,15 +6,17 @@ /// Type-safe CORS configuration properties bound from `spring.web.cors`. @ConfigurationProperties(prefix = "spring.web.cors") -public record CorsProperties( - List allowedOrigins, - List allowedOriginPatterns -) { - /// Compact constructor: normalises null to empty and defensively copies every list - /// to prevent external mutation of the internal state (EI_EXPOSE_REP / EI_EXPOSE_REP2). - /// List.copyOf() also rejects null elements, enforcing a clean configuration contract. - public CorsProperties { - allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); - allowedOriginPatterns = allowedOriginPatterns == null ? List.of() : List.copyOf(allowedOriginPatterns); - } +public record CorsProperties(List allowedOrigins, List allowedOriginPatterns) { + /// Compact constructor: normalises null to empty and defensively copies every + /// list + /// to prevent external mutation of the internal state (EI_EXPOSE_REP / + /// EI_EXPOSE_REP2). + /// List.copyOf() also rejects null elements, enforcing a clean configuration + /// contract. + public CorsProperties { + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + allowedOriginPatterns = allowedOriginPatterns == null + ? List.of() + : List.copyOf(allowedOriginPatterns); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java index 9bd7861..c90315d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java @@ -22,13 +22,12 @@ @Configuration public class JwtConfiguration { - @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") - private String jwkSetUri; + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + private String jwkSetUri; - @Bean - @ConditionalOnMissingBean - public JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withJwkSetUri(jwkSetUri) - .build(); - } + @Bean + @ConditionalOnMissingBean + public JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 9242ef9..8a492d2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -32,44 +32,40 @@ @EnableConfigurationProperties(CorsProperties.class) public class SecurityConfiguration { - private final CorsProperties corsProperties; + private final CorsProperties corsProperties; - public SecurityConfiguration(CorsProperties corsProperties) { - this.corsProperties = corsProperties; - } + public SecurityConfiguration(CorsProperties corsProperties) { + this.corsProperties = corsProperties; + } - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) { - http.authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() - .requestMatchers("/api/v1/**").fullyAuthenticated() - .anyRequest().authenticated() - ) - .cors(withDefaults()) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); - return http.build(); - } + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/actuator/**").permitAll() + .requestMatchers("/", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() + .requestMatchers("/api/v1/**").fullyAuthenticated().anyRequest().authenticated()) + .cors(withDefaults()).oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + return http.build(); + } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); - // Exact origins (no wildcard, safe with allowCredentials) - if (!corsProperties.allowedOrigins().isEmpty()) { - configuration.setAllowedOrigins(corsProperties.allowedOrigins()); - } + // Exact origins (no wildcard, safe with allowCredentials) + if (!corsProperties.allowedOrigins().isEmpty()) { + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); + } - if (!corsProperties.allowedOriginPatterns().isEmpty()) { - configuration.setAllowedOriginPatterns(corsProperties.allowedOriginPatterns()); - } + if (!corsProperties.allowedOriginPatterns().isEmpty()) { + configuration.setAllowedOriginPatterns(corsProperties.allowedOriginPatterns()); + } - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java index e426d98..43285da 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.web.config.EnableSpringDataWebSupport; - /// Spring Data Web configuration optimizing REST API response serialization. /// /// **Infrastructure rationale:** Configures clean DTO-style pagination responses instead @@ -21,9 +20,7 @@ /// **Alternative avoided:** Default HATEOAS format includes `_links` and `_embedded` /// properties that increase response size and complexity for simple API consumption. @Configuration -@EnableSpringDataWebSupport( - pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO -) +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) public class SpringDataWebConfiguration { } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index e9eac3e..a0e0e7c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java @@ -49,59 +49,51 @@ @Profile("!test") public class SwaggerConfiguration { - public static final String AUTHENTICATION_SUFFIX = " authentication"; - public static final String CLIENT_ID = "clientId"; - public static final String BEARER = "bearer"; - - @Value("${spring.security.oauth2.client.provider.idp-core.token-uri}") - private String oauth2url; - - @Value("${app.idp-core-prefix-url}") - private String idpCorePrefixUrl; - - @Bean - public OpenAPI openAPI() { - ModelConverters.getInstance().addConverter(new ModelResolver(Json.mapper())); - return new OpenAPI() - .info(new Info() - .title("Idp core API") - .description("API dedicated to idp core functionalities") - .version("v1")) - .addServersItem(new Server().url(idpCorePrefixUrl)) - .schemaRequirement(CLIENT_ID, - new SecurityScheme().description(CLIENT_ID + AUTHENTICATION_SUFFIX) - .name(CLIENT_ID) - .type(OAUTH2) - .flows(new OAuthFlows().clientCredentials( - new OAuthFlow().tokenUrl(oauth2url)))) - .addSecurityItem(new SecurityRequirement().addList(CLIENT_ID)) - .schemaRequirement(BEARER, - new SecurityScheme().description(BEARER + AUTHENTICATION_SUFFIX) - .name(BEARER) - .scheme(BEARER) - .bearerFormat("JWT") - .type(HTTP)) - .addSecurityItem(new SecurityRequirement().addList(BEARER)); - } - - @Bean - public GroupedOpenApi allApis() { - return GroupedOpenApi.builder().group("internal").pathsToMatch("/**").build(); - } - - @Schema(description = "Paginated response containing Template objects") - public static class TemplatePageResponse extends PageImpl { - public TemplatePageResponse(List content, Pageable pageable, long total) { - super(content, pageable, total); - } + public static final String AUTHENTICATION_SUFFIX = " authentication"; + public static final String CLIENT_ID = "clientId"; + public static final String BEARER = "bearer"; + + @Value("${spring.security.oauth2.client.provider.idp-core.token-uri}") + private String oauth2url; + + @Value("${app.idp-core-prefix-url}") + private String idpCorePrefixUrl; + + @Bean + public OpenAPI openAPI() { + ModelConverters.getInstance().addConverter(new ModelResolver(Json.mapper())); + return new OpenAPI() + .info(new Info().title("Idp core API") + .description("API dedicated to idp core functionalities").version("v1")) + .addServersItem(new Server().url(idpCorePrefixUrl)) + .schemaRequirement(CLIENT_ID, + new SecurityScheme().description(CLIENT_ID + AUTHENTICATION_SUFFIX).name(CLIENT_ID) + .type(OAUTH2) + .flows(new OAuthFlows().clientCredentials(new OAuthFlow().tokenUrl(oauth2url)))) + .addSecurityItem(new SecurityRequirement().addList(CLIENT_ID)) + .schemaRequirement(BEARER, + new SecurityScheme().description(BEARER + AUTHENTICATION_SUFFIX).name(BEARER) + .scheme(BEARER).bearerFormat("JWT").type(HTTP)) + .addSecurityItem(new SecurityRequirement().addList(BEARER)); + } + + @Bean + public GroupedOpenApi allApis() { + return GroupedOpenApi.builder().group("internal").pathsToMatch("/**").build(); + } + + @Schema(description = "Paginated response containing Template objects") + public static class TemplatePageResponse extends PageImpl { + public TemplatePageResponse(List content, Pageable pageable, long total) { + super(content, pageable, total); } + } - @Schema(description = "Paginated response containing Entity objects") - public static class EntityPageResponse extends PageImpl { - public EntityPageResponse(List content, Pageable pageable, long total) { - super(content, pageable, total); - } + @Schema(description = "Paginated response containing Entity objects") + public static class EntityPageResponse extends PageImpl { + public EntityPageResponse(List content, Pageable pageable, long total) { + super(content, pageable, total); } - + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index feb6d60..e6976b8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -20,152 +20,150 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class SwaggerDescription { - /// HTTP response status codes for OpenAPI documentation - public static final String OK_CODE = "200"; - public static final String CREATED_CODE = "201"; - public static final String NO_CONTENT_CODE = "204"; - public static final String PARTIAL_CONTENT_CODE = "206"; - public static final String BAD_REQUEST_CODE = "400"; - public static final String UNAUTHORIZED_CODE = "401"; - public static final String FORBIDDEN_CODE = "403"; - public static final String NOT_FOUND_CODE = "404"; - public static final String CONFLICT_CODE = "409"; - public static final String SERVICE_UNAVAILABLE_CODE = "503"; - public static final String INTERNAL_SERVER_ERROR_CODE = "500"; - - /// Entity Template API endpoint constants - public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; - public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; - - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; - - public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; - public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; - public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; - public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; - - public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; - public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; - - /// Entity API endpoint constants - public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; - public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; - - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; - - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; - - public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; - public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; - - - /// API response description constants - public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; - public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; - public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; - public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; - public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; - public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; - public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; - public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; - public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; - public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; - public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; - public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; - public static final String RESPONSE_ENTITY_FOUND = "Entity found"; - public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; - public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; - public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; - public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; - public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; - public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; - - - // --- Schema (class) descriptions --- - public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; - public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; - public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; - public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; - public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; - public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; - public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; - public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; - public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; - - // --- Field descriptions (shared) --- - public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; - public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; - public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; - public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; - public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; - public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; - - public static final String FIELD_ENTITY_NAME = "Name of the entity"; - public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; - public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; - public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; - public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; - public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; - - public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; - public static final String FIELD_PROPERTY_NAME = "Property name"; - public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; - public static final String FIELD_PROPERTY_TYPE = "Property data type"; - public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; - public static final String FIELD_PROPERTY_RULES = "Property validation rules"; - - public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; - public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; - public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; - public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; - public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; - public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; - public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; - public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; - public static final String FIELD_CREATED_AT = "Creation timestamp"; - public static final String FIELD_UPDATED_AT = "Last update timestamp"; - - public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; - public static final String FIELD_RELATION_NAME = "Name of the relation"; - public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; - public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; - public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; - - // --- Pagination and sorting parameter descriptions --- - public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; - public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - - // --- Entity Graph (flat nodes & edges) descriptions --- - public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; - 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 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"; - public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; - public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; - public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; - public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; - public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; - public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; - public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; - public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; - public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; - public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; - public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; + /// HTTP response status codes for OpenAPI documentation + public static final String OK_CODE = "200"; + public static final String CREATED_CODE = "201"; + public static final String NO_CONTENT_CODE = "204"; + public static final String PARTIAL_CONTENT_CODE = "206"; + public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; + public static final String NOT_FOUND_CODE = "404"; + public static final String CONFLICT_CODE = "409"; + public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; + + /// Entity Template API endpoint constants + public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; + public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; + + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; + + public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; + public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; + public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; + public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; + + public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; + public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; + + /// Entity API endpoint constants + public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; + public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; + + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; + + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; + + public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; + public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; + + /// API response description constants + public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; + public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; + public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; + public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; + public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; + public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; + public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; + public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; + public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; + public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; + public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; + public static final String RESPONSE_ENTITY_FOUND = "Entity found"; + public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; + public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; + public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; + + // --- Schema (class) descriptions --- + public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; + public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; + public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; + public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; + public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; + public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; + public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; + + // --- Field descriptions (shared) --- + public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; + public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; + public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; + public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; + public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; + public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; + public static final String FIELD_PROPERTY_NAME = "Property name"; + public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; + public static final String FIELD_PROPERTY_TYPE = "Property data type"; + public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; + public static final String FIELD_PROPERTY_RULES = "Property validation rules"; + + public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; + public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; + public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; + public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; + public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; + public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; + public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; + public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; + public static final String FIELD_CREATED_AT = "Creation timestamp"; + public static final String FIELD_UPDATED_AT = "Last update timestamp"; + + public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; + public static final String FIELD_RELATION_NAME = "Name of the relation"; + public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; + public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; + public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; + + // --- Pagination and sorting parameter descriptions --- + public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + + // --- Entity Graph (flat nodes & edges) descriptions --- + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + 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 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"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java index 41a5e37..cab1b62 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java @@ -16,9 +16,9 @@ @Configuration public class WebConfiguration implements WebMvcConfigurer { - @Override - public void addViewControllers(ViewControllerRegistry registry) { - registry.addRedirectViewController("/", "swagger-ui/index.html"); - } + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addRedirectViewController("/", "swagger-ui/index.html"); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index f9f8d90..e8fb346 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -31,7 +31,9 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -53,6 +55,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -60,8 +63,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; /// REST API adapter providing entity management endpoints. /// @@ -77,85 +79,96 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; + private final EntityService entityService; + private final EntityDtoOutMapper entityDtoOutMapper; + private final EntityDtoInMapper entityDtoInMapper; - /// Returns paginated entities filtered by template with HTTP pagination support. - /// - /// **API contract:** Provides paginated entity listings for template-specific views. - /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. - /// Template validation is handled by the domain service layer. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control - /// @param templateIdentifier template filter for entity scope limitation - /// @return paginated entity DTOs optimized for API consumers - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @PathVariable String templateIdentifier) { - Pageable pageable = PageRequest.of(page, size); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. + /// Supports standard REST pagination parameters and returns appropriate HTTP + /// status codes. + /// Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @return paginated entity DTOs optimized for API consumers + @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier) { + Pageable pageable = PageRequest.of(page, size); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier); + return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + } - /// Retrieves a single entity by template and entity identifiers. - /// - /// **API contract:** Provides specific entity lookup using compound identifier pattern. - /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. - /// - /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context - /// @return entity DTO with full property and relationship data - @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut getEntity( - @PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); - return entityDtoOutMapper.fromEntity(entity); - } + /// Retrieves a single entity by template and entity identifiers. + /// + /// **API contract:** Provides specific entity lookup using compound identifier + /// pattern. + /// Returns HTTP 404 if either template or entity doesn't exist, maintaining + /// REST semantics. + /// + /// @param templateIdentifier business template identifier for entity scope + /// @param entityIdentifier unique business identifier within template context + /// @return entity DTO with full property and relationship data + @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut getEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier) { + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier); + return entityDtoOutMapper.fromEntity(entity); + } - /// Creates a new entity for the specified template with validation. - /// - /// **API contract:** Accepts entity creation payload and returns created entity with - /// generated identifiers. Validates entity structure against template constraints - /// and returns HTTP 201 on success, HTTP 400 for validation errors. - /// - /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships - /// @return created entity DTO with server-generated identifiers - @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PostMapping("/{templateIdentifier}") - @ResponseStatus(CREATED) - public EntityDtoOut createEntity( - @NotBlank @PathVariable String templateIdentifier, - @Valid @RequestBody EntityDtoIn entityDtoIn) { + /// Creates a new entity for the specified template with validation. + /// + /// **API contract:** Accepts entity creation payload and returns created entity + /// with + /// generated identifiers. Validates entity structure against template + /// constraints + /// and returns HTTP 201 on success, HTTP 400 for validation errors. + /// + /// @param templateIdentifier target template identifier for entity creation + /// context + /// @param entityDtoIn entity creation payload with properties and relationships + /// @return created entity DTO with server-generated identifiers + @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/{templateIdentifier}") + @ResponseStatus(CREATED) + public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { - Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 7ea3de4..a90639b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -15,6 +15,8 @@ import java.util.List; import java.util.Set; +import jakarta.validation.constraints.NotBlank; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -34,7 +36,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; /// REST controller for entity relationship graph operations. @@ -48,52 +49,44 @@ @Tag(name = "Entity Graph", description = "Entity relationship graph operations") public class EntityGraphController { - private final EntityGraphService entityGraphService; + private final EntityGraphService entityGraphService; - /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. - /// - /// Returns all entities as nodes and all directed relations as edges. Nodes are - /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, - /// Cytoscape, and similar frontend graph visualization libraries. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @param includeData when true, each node includes a data object with entity property values - /// @param relations when provided, only relations with matching names are included - /// @param properties when provided, each node's data object is restricted to the listed property names - /// @return flat DTO containing nodes and edges arrays - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") - @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 = 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, - @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth, - @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, - @Parameter(description = PARAM_RELATIONS_DESCRIPTION) - @RequestParam(required = false) List relations, - @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) - @RequestParam(required = false) List properties) { + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// Returns all entities as nodes and all directed relations as edges. Nodes are + /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, + /// Cytoscape, and similar frontend graph visualization libraries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and + /// 10) + /// @param includeData when true, each node includes a data object with entity + /// property values + /// @param relations when provided, only relations with matching names are + /// included + /// @param properties when provided, each node's data object is restricted to + /// the listed property names + /// @return flat DTO containing nodes and edges arrays + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") + @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 = 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, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { - // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter - Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); - Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); + // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); - EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth, includeData); + EntityGraphNode graphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); - } + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index fa0f947..b82c6a3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -30,6 +30,8 @@ import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; +import jakarta.validation.Valid; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -60,7 +62,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// REST API adapter for Entity Template management operations. @@ -86,93 +87,99 @@ @Tag(name = "Entities Templates Management", description = "Operations related to entity template management") public class EntityTemplateController { - private final EntityTemplateService entityTemplateService; - private final EntityTemplateMapper templateMapper; + private final EntityTemplateService entityTemplateService; + private final EntityTemplateMapper templateMapper; - /// Retrieves paginated entity templates for administrative interfaces. - /// - /// **API contract:** Provides paginated template listings with configurable sorting - /// and page size. Defaults to 20 templates per page sorted by identifier for - /// consistent management interface display. - @Operation(summary = ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY, description = ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = TemplatePageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @GetMapping - @ResponseStatus(OK) - public Page getTemplatesPaginated( - @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { - Page templates = entityTemplateService.getEntityTemplates(pageable); - return templates.map(templateMapper::fromEntityTemplatetoDto); - } + /// Retrieves paginated entity templates for administrative interfaces. + /// + /// **API contract:** Provides paginated template listings with configurable + /// sorting + /// and page size. Defaults to 20 templates per page sorted by identifier for + /// consistent management interface display. + @Operation(summary = ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY, description = ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = TemplatePageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @GetMapping + @ResponseStatus(OK) + public Page getTemplatesPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + Page templates = entityTemplateService.getEntityTemplates(pageable); + return templates.map(templateMapper::fromEntityTemplatetoDto); + } - /// Retrieves specific entity template by business identifier. - /// - /// **API contract:** Returns complete template definition using case-sensitive - /// business identifier lookup. Provides HTTP 404 for non-existent templates - /// with meaningful error messages for API consumers. - @Operation(summary = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_FOUND, content = { - @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @GetMapping("/{identifier}") - @ResponseStatus(OK) - public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identifier) { - EntityTemplate entity = entityTemplateService.getEntityTemplateByIdentifier(identifier); - return templateMapper.fromEntityTemplatetoDto(entity); - } + /// Retrieves specific entity template by business identifier. + /// + /// **API contract:** Returns complete template definition using case-sensitive + /// business identifier lookup. Provides HTTP 404 for non-existent templates + /// with meaningful error messages for API consumers. + @Operation(summary = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_FOUND, content = { + @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identifier) { + EntityTemplate entity = entityTemplateService.getEntityTemplateByIdentifier(identifier); + return templateMapper.fromEntityTemplatetoDto(entity); + } - /// Creates new entity template with validation and uniqueness checks. - /// - /// **API contract:** Accepts template creation payload with comprehensive validation. - /// Returns HTTP 201 with created template including generated identifiers, or - /// HTTP 409 for duplicate identifier conflicts. - @Operation(summary = ENDPOINT_POST_TEMPLATE_SUMMARY, description = ENDPOINT_POST_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_TEMPLATE_CREATED, content = { - @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_TEMPLATE_DATA, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @PostMapping - @ResponseStatus(CREATED) - public EntityTemplateDtoOut createTemplate(@Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn) { - EntityTemplate entityTemplate = entityTemplateService.createEntityTemplate(templateMapper.fromDtoToEntityTemplate(entityTemplateCreateDtoIn)); - return templateMapper.fromEntityTemplatetoDto(entityTemplate); - } + /// Creates new entity template with validation and uniqueness checks. + /// + /// **API contract:** Accepts template creation payload with comprehensive + /// validation. + /// Returns HTTP 201 with created template including generated identifiers, or + /// HTTP 409 for duplicate identifier conflicts. + @Operation(summary = ENDPOINT_POST_TEMPLATE_SUMMARY, description = ENDPOINT_POST_TEMPLATE_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_TEMPLATE_CREATED, content = { + @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_TEMPLATE_DATA, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PostMapping + @ResponseStatus(CREATED) + public EntityTemplateDtoOut createTemplate( + @Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn) { + EntityTemplate entityTemplate = entityTemplateService + .createEntityTemplate(templateMapper.fromDtoToEntityTemplate(entityTemplateCreateDtoIn)); + return templateMapper.fromEntityTemplatetoDto(entityTemplate); + } - /// Updates existing entity template with complete replacement strategy. - /// - /// **API contract:** Replaces entire template definition while preserving identifier. - /// Returns updated template with HTTP 200, or HTTP 404 for non-existent templates. - @Operation(summary = ENDPOINT_PUT_TEMPLATE_SUMMARY, description = ENDPOINT_PUT_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_UPDATED, content = { - @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) - @ApiResponse(responseCode = "404", description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @PutMapping("/{identifier}") - public EntityTemplateDtoOut updateTemplate( - @PathVariable(name = "identifier") String identifier, - @RequestBody @Valid EntityTemplateUpdateDtoIn entityTemplateUpdateDtoIn) { - EntityTemplate entityTemplate = entityTemplateService.updateEntityTemplate(identifier, templateMapper.fromPutDtoToEntityTemplate(identifier, entityTemplateUpdateDtoIn)); - return templateMapper.fromEntityTemplatetoDto(entityTemplate); - } + /// Updates existing entity template with complete replacement strategy. + /// + /// **API contract:** Replaces entire template definition while preserving + /// identifier. + /// Returns updated template with HTTP 200, or HTTP 404 for non-existent + /// templates. + @Operation(summary = ENDPOINT_PUT_TEMPLATE_SUMMARY, description = ENDPOINT_PUT_TEMPLATE_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) + @ApiResponse(responseCode = "404", description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + public EntityTemplateDtoOut updateTemplate(@PathVariable(name = "identifier") String identifier, + @RequestBody @Valid EntityTemplateUpdateDtoIn entityTemplateUpdateDtoIn) { + EntityTemplate entityTemplate = entityTemplateService.updateEntityTemplate(identifier, + templateMapper.fromPutDtoToEntityTemplate(identifier, entityTemplateUpdateDtoIn)); + return templateMapper.fromEntityTemplatetoDto(entityTemplate); + } - /// Deletes entity template by business identifier with safety checks. - /// - /// **API contract:** Permanently removes template with HTTP 204 response. - /// Operation is idempotent - returns success even for non-existent templates. - /// **Warning:** Irreversible operation requiring referential integrity validation. - @Operation(summary = ENDPOINT_DELETE_TEMPLATE_SUMMARY, description = ENDPOINT_DELETE_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_TEMPLATE_DELETED) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) - }) - @ResponseStatus(NO_CONTENT) - @DeleteMapping("/{identifier}") - public void deleteTemplate(@PathVariable String identifier) { - entityTemplateService.deleteEntityTemplate(identifier); - } + /// Deletes entity template by business identifier with safety checks. + /// + /// **API contract:** Permanently removes template with HTTP 204 response. + /// Operation is idempotent - returns success even for non-existent templates. + /// **Warning:** Irreversible operation requiring referential integrity + /// validation. + @Operation(summary = ENDPOINT_DELETE_TEMPLATE_SUMMARY, description = ENDPOINT_DELETE_TEMPLATE_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_TEMPLATE_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteTemplate(@PathVariable String identifier) { + entityTemplateService.deleteEntityTemplate(identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 7587711..81de10a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; @@ -8,21 +12,18 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import java.util.List; import java.util.Map; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -41,39 +42,39 @@ @Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { - @NotBlank(message = ENTITY_NAME_MANDATORY) - @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") - private String name; + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") + private String name; - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") - private String identifier; + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") + private String identifier; - @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") + private Map properties; - @Valid - @Schema(description = FIELD_ENTITY_RELATIONS) - private List relations; + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) + private List relations; - /// Input DTO for an entity relation instance. - /// - /// **Infrastructure validation:** Validates relation name presence and target - /// identifiers at the API boundary before domain-level schema checks. - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonNaming(SnakeCaseStrategy.class) - @Schema(description = SCHEMA_ENTITY_RELATION_IN) - public static class RelationDtoIn { + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) + public static class RelationDtoIn { - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") - private String name; + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") + private String name; - @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) - @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") - private List targetEntityIdentifiers; - } + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") + private List targetEntityIdentifiers; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java index d6456d4..c202f0e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java @@ -4,13 +4,14 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_TEMPLATE_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_TEMPLATE_CREATE_IN; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -19,10 +20,11 @@ /** * **Input DTO for creating entity templates.** * - * - Used as the request body for POST operations on entity templates. - * - Composes all updatable fields from {@link EntityTemplateCommonFields} and flattens them into the top-level JSON using {@code @JsonUnwrapped}. - * - Fields are validated using Jakarta Validation annotations. - * - Follows composition over inheritance for maintainability and clarity. + * - Used as the request body for POST operations on entity templates. - + * Composes all updatable fields from {@link EntityTemplateCommonFields} and + * flattens them into the top-level JSON using {@code @JsonUnwrapped}. - Fields + * are validated using Jakarta Validation annotations. - Follows composition + * over inheritance for maintainability and clarity. * * @see EntityTemplateCommonFields */ @@ -34,11 +36,11 @@ @Schema(description = SCHEMA_ENTITY_TEMPLATE_CREATE_IN) public class EntityTemplateCreateDtoIn { - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") - private String identifier; + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") + private String identifier; - @Valid - @JsonUnwrapped - private EntityTemplateDtoInCommonFields commonFields; + @Valid + @JsonUnwrapped + private EntityTemplateDtoInCommonFields commonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java index 3b55f24..4a99fdb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java @@ -11,21 +11,23 @@ import java.util.List; -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** - * Common fields shared between EntityTemplateCreateDtoIn (POST) and EntityTemplateUpdateDtoIn (PUT). + * Common fields shared between EntityTemplateCreateDtoIn (POST) and + * EntityTemplateUpdateDtoIn (PUT). */ @Data @Builder @@ -34,20 +36,20 @@ @JsonNaming(SnakeCaseStrategy.class) public class EntityTemplateDtoInCommonFields { - @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) - @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") - @NotBlank(message = TEMPLATE_NAME_MANDATORY) - @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) - private String name; + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) + @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") + @NotBlank(message = TEMPLATE_NAME_MANDATORY) + @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) + private String name; - @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") - private String description; + @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") + private String description; - @Valid - @Schema(description = FIELD_TEMPLATE_PROPERTIES) - private List propertiesDefinitions; + @Valid + @Schema(description = FIELD_TEMPLATE_PROPERTIES) + private List propertiesDefinitions; - @Valid - @Schema(description = FIELD_TEMPLATE_RELATIONS) - private List relationsDefinitions; + @Valid + @Schema(description = FIELD_TEMPLATE_RELATIONS) + private List relationsDefinitions; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java index 8e3b1c3..39295b7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java @@ -1,25 +1,27 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_TEMPLATE_UPDATE_IN; + +import jakarta.validation.Valid; + import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_TEMPLATE_UPDATE_IN; - /** * **Input DTO for updating entity templates.** * - * - Used as the request body for PUT operations on entity templates. - * - Composes all updatable fields from {@link EntityTemplateCommonFields} and flattens them into the top-level JSON using {@code @JsonUnwrapped}. - * - Fields are validated using Jakarta Validation annotations. - * - Follows composition over inheritance for maintainability and clarity. + * - Used as the request body for PUT operations on entity templates. - Composes + * all updatable fields from {@link EntityTemplateCommonFields} and flattens + * them into the top-level JSON using {@code @JsonUnwrapped}. - Fields are + * validated using Jakarta Validation annotations. - Follows composition over + * inheritance for maintainability and clarity. * * @see EntityTemplateCommonFields */ @@ -31,7 +33,7 @@ @Schema(description = SCHEMA_ENTITY_TEMPLATE_UPDATE_IN) public class EntityTemplateUpdateDtoIn { - @Valid - @JsonUnwrapped - private EntityTemplateDtoInCommonFields commonFields; + @Valid + @JsonUnwrapped + private EntityTemplateDtoInCommonFields commonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java index 80e74e4..bb32873 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java @@ -10,14 +10,15 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_TYPE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_PROPERTY_DEFINITION_IN; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -30,23 +31,23 @@ @JsonNaming(SnakeCaseStrategy.class) @Schema(description = SCHEMA_PROPERTY_DEFINITION_IN) public class PropertyDefinitionDtoIn { - @NotBlank(message = PROPERTY_NAME_MANDATORY) - @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") - private String name; + @NotBlank(message = PROPERTY_NAME_MANDATORY) + @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") + private String name; - @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) - @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") - private String description; + @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) + @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") + private String description; - @NotNull(message = PROPERTY_TYPE_MANDATORY) - @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") - private PropertyType type; + @NotNull(message = PROPERTY_TYPE_MANDATORY) + @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") + private PropertyType type; - @Builder.Default - @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true", defaultValue = "false") - private boolean required = false; + @Builder.Default + @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true", defaultValue = "false") + private boolean required = false; - @Valid - @Schema(description = FIELD_PROPERTY_RULES) - private PropertyRulesDtoIn rules; + @Valid + @Schema(description = FIELD_PROPERTY_RULES) + private PropertyRulesDtoIn rules; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java index 04a7511..761758d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java @@ -20,24 +20,24 @@ @Schema(description = SCHEMA_PROPERTY_DEFINITION_IN) public class PropertyRulesDtoIn { - @Schema(description = "Property format validation", example = "EMAIL") - private PropertyFormat format; + @Schema(description = "Property format validation", example = "EMAIL") + private PropertyFormat format; - @Schema(description = "Enumeration values for enum properties", example = "[\"ACTIVE\", \"INACTIVE\"]") - private String[] enumValues; + @Schema(description = "Enumeration values for enum properties", example = "[\"ACTIVE\", \"INACTIVE\"]") + private String[] enumValues; - @Schema(description = "Regular expression pattern for validation", example = "^[a-zA-Z0-9]+$") - private String regex; + @Schema(description = "Regular expression pattern for validation", example = "^[a-zA-Z0-9]+$") + private String regex; - @Schema(description = "Maximum length for string properties", example = "255") - private Integer maxLength; + @Schema(description = "Maximum length for string properties", example = "255") + private Integer maxLength; - @Schema(description = "Minimum length for string properties", example = "1") - private Integer minLength; + @Schema(description = "Minimum length for string properties", example = "1") + private Integer minLength; - @Schema(description = "Maximum value for numeric properties", example = "100") - private Integer maxValue; + @Schema(description = "Maximum value for numeric properties", example = "100") + private Integer maxValue; - @Schema(description = "Minimum value for numeric properties", example = "0") - private Integer minValue; + @Schema(description = "Minimum value for numeric properties", example = "0") + private Integer minValue; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java index be1b991..48a954b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java @@ -8,11 +8,12 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_RELATION_TO_MANY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_RELATION_DEFINITION_IN; +import jakarta.validation.constraints.NotBlank; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,19 +27,19 @@ @Schema(description = SCHEMA_RELATION_DEFINITION_IN) public class RelationDefinitionDtoIn { - @NotBlank(message = RELATION_NAME_MANDATORY) - @Schema(description = FIELD_RELATION_NAME, example = "dependencies") - private String name; + @NotBlank(message = RELATION_NAME_MANDATORY) + @Schema(description = FIELD_RELATION_NAME, example = "dependencies") + private String name; - @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "service") - private String targetTemplateIdentifier; + @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "service") + private String targetTemplateIdentifier; - @Builder.Default - @Schema(description = FIELD_RELATION_REQUIRED, example = "false", defaultValue = "false") - private boolean required = false; + @Builder.Default + @Schema(description = FIELD_RELATION_REQUIRED, example = "false", defaultValue = "false") + private boolean required = false; - @Builder.Default - @Schema(description = FIELD_RELATION_TO_MANY, example = "true", defaultValue = "false") - private boolean toMany = false; + @Builder.Default + @Schema(description = FIELD_RELATION_TO_MANY, example = "true", defaultValue = "false") + private boolean toMany = false; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java index c927805..13e4c6d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java @@ -14,11 +14,11 @@ @JsonNaming(SnakeCaseStrategy.class) public class EntityDtoOut { - private String templateIdentifier; - private String name; - private String identifier; - private Map properties; - private Map> relations; - private Map> relationsAsTarget; + private String templateIdentifier; + private String name; + private String identifier; + private Map properties; + private Map> relations; + private Map> relationsAsTarget; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java index c61800d..d94bef1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java @@ -17,15 +17,11 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphEdgeDtoOut( - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) - String id, + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) String id, - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) - String source, + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) String source, - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) - String target, + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) String target, - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) - String type -) {} + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) String type) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java index aa43eb8..a612785 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java @@ -19,15 +19,12 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphFlatDtoOut( - @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) - List nodes, + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) List nodes, - @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) - List edges -) { - /// Defensive copies prevent external mutation of the returned collections. - public EntityGraphFlatDtoOut { - nodes = nodes != null ? List.copyOf(nodes) : List.of(); - edges = edges != null ? List.copyOf(edges) : List.of(); - } + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) List edges) { + /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + edges = edges != null ? List.copyOf(edges) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index c1fa208..45fad52 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -25,25 +25,19 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeFlatDtoOut( - @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) - String id, - - @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) - String label, - - @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) - String templateIdentifier, - - @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) - String identifier, - - @JsonInclude(Include.NON_EMPTY) - @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) - Map data -) { - /// Compact constructor: defensively copies the data map to prevent external mutation - /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). - public EntityGraphNodeFlatDtoOut { - data = data == null ? Map.of() : Map.copyOf(data); - } + @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) String id, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) String label, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) String templateIdentifier, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) String identifier, + + @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data) { + /// Compact constructor: defensively copies the data map to prevent external + /// mutation + /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java index 8611f71..c7641dc 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java @@ -12,6 +12,6 @@ @AllArgsConstructor @JsonNaming(SnakeCaseStrategy.class) public class EntitySummaryDto { - private String identifier; - private String name; + private String identifier; + private String name; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java index 1e734bd..b5a12ae 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -5,9 +5,6 @@ /// Output DTO representing an incoming relationship where the entity is the target. @JsonNaming(SnakeCaseStrategy.class) -public record RelationAsTargetSummaryDtoOut( - String targetEntityIdentifier, - String relationName, - String sourceEntityIdentifier, - String sourceEntityName -) {} +public record RelationAsTargetSummaryDtoOut(String targetEntityIdentifier, String relationName, + String sourceEntityIdentifier, String sourceEntityName) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java index ea0ad86..f829252 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java @@ -1,17 +1,18 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; + +import java.util.List; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.List; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; - @Data @Builder @NoArgsConstructor @@ -20,18 +21,18 @@ @Schema(description = "Output for entity template") public class EntityTemplateDtoOut { - @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") - private String identifier; + @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") + private String identifier; - @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") - private String name; + @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") + private String name; - @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") - private String description; + @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") + private String description; - @Schema(description = FIELD_TEMPLATE_PROPERTIES) - private List propertiesDefinitions; + @Schema(description = FIELD_TEMPLATE_PROPERTIES) + private List propertiesDefinitions; - @Schema(description = FIELD_TEMPLATE_RELATIONS) - private List relationsDefinitions; + @Schema(description = FIELD_TEMPLATE_RELATIONS) + private List relationsDefinitions; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java index b26f00d..12d6408 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java @@ -1,16 +1,17 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; + import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; - @Data @Builder @NoArgsConstructor @@ -19,18 +20,18 @@ @Schema(description = SCHEMA_PROPERTY_DEFINITION_OUT) public class PropertyDefinitionDtoOut { - @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") - private String name; + @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") + private String name; - @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") - private String description; + @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") + private String description; - @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") - private PropertyType type; + @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") + private PropertyType type; - @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true") - private boolean required; + @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true") + private boolean required; - @Schema(description = FIELD_PROPERTY_RULES, example = "Property validation rules") - private PropertyRulesDtoOut rules; + @Schema(description = FIELD_PROPERTY_RULES, example = "Property validation rules") + private PropertyRulesDtoOut rules; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java index c754eea..634930e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java @@ -27,25 +27,25 @@ @Schema(description = SCHEMA_PROPERTY_RULES_OUT) public class PropertyRulesDtoOut { - @Schema(description = FIELD_PROPERTY_RULES_FORMAT, example = "STRING") - private PropertyFormat format; + @Schema(description = FIELD_PROPERTY_RULES_FORMAT, example = "STRING") + private PropertyFormat format; - @Schema(description = FIELD_PROPERTY_RULES_ENUM_VALUES, example = "[\"VALUE1\", \"VALUE2\"]") - private String[] enumValues; + @Schema(description = FIELD_PROPERTY_RULES_ENUM_VALUES, example = "[\"VALUE1\", \"VALUE2\"]") + private String[] enumValues; - @Schema(description = FIELD_PROPERTY_RULES_REGEX, example = "^[A-Za-z0-9]+$") - private String regex; + @Schema(description = FIELD_PROPERTY_RULES_REGEX, example = "^[A-Za-z0-9]+$") + private String regex; - @Schema(description = FIELD_PROPERTY_RULES_MAX_LENGTH, example = "255") - private Integer maxLength; + @Schema(description = FIELD_PROPERTY_RULES_MAX_LENGTH, example = "255") + private Integer maxLength; - @Schema(description = FIELD_PROPERTY_RULES_MIN_LENGTH, example = "1") - private Integer minLength; + @Schema(description = FIELD_PROPERTY_RULES_MIN_LENGTH, example = "1") + private Integer minLength; - @Schema(description = FIELD_PROPERTY_RULES_MAX_VALUE, example = "100") - private Integer maxValue; + @Schema(description = FIELD_PROPERTY_RULES_MAX_VALUE, example = "100") + private Integer maxValue; - @Schema(description = FIELD_PROPERTY_RULES_MIN_VALUE, example = "0") - private Integer minValue; + @Schema(description = FIELD_PROPERTY_RULES_MIN_VALUE, example = "0") + private Integer minValue; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java index fe5f39a..85bb6d1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java @@ -22,16 +22,16 @@ @Schema(description = "Output DTO for relation definition") public class RelationDefinitionDtoOut { - @Schema(description = FIELD_RELATION_NAME, example = "dependencies") - private String name; + @Schema(description = FIELD_RELATION_NAME, example = "dependencies") + private String name; - @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "component-template") - private String targetTemplateIdentifier; + @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "component-template") + private String targetTemplateIdentifier; - @Schema(description = FIELD_RELATION_REQUIRED, example = "false") - private boolean required; + @Schema(description = FIELD_RELATION_REQUIRED, example = "false") + private boolean required; - @Schema(description = FIELD_RELATION_TO_MANY, example = "true") - private boolean toMany; + @Schema(description = FIELD_RELATION_TO_MANY, example = "true") + private boolean toMany; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 33b3361..9857971 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -7,6 +7,9 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -30,8 +33,6 @@ import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -55,314 +56,333 @@ @ControllerAdvice public class ApiExceptionHandler { - private ApiExceptionHandler() { - } - - /// Handles domain exception when entity templates are not found. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 status - /// with business-meaningful error message for API consumers. - @ExceptionHandler(EntityTemplateNotFoundException.class) - public ResponseEntity handleTemplateNotFoundException(EntityTemplateNotFoundException ex) { - log.warn("Template not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); - } - - /// Handles domain exception when entity templates already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate identifiers. - @ExceptionHandler(EntityTemplateAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateAlreadyExistsException( - EntityTemplateAlreadyExistsException ex) { - log.warn("Entity template already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when entity template names already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate template names. - @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateNameAlreadyExistsException( - EntityTemplateNameAlreadyExistsException ex) { - log.warn("Entity template name already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when attempting to change an entity template identifier. - /// - /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException to HTTP 400 - /// status indicating validation error for immutable identifier field. - @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) - public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( - EntityTemplateIdentifierCannotChangeException ex) { - log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception for wrong entity template property rules. - /// - /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 - /// status indicating validation error for wrong property rules. - @ExceptionHandler(PropertyDefinitionRulesConflictException.class) - public ResponseEntity handleWrongPropertyRulesException( - PropertyDefinitionRulesConflictException ex) { - log.warn("Wrong Entity template property rules: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception when property names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate property names. - @ExceptionHandler(PropertyNameAlreadyExistsException.class) - public ResponseEntity handlePropertyNameAlreadyExistsException( - PropertyNameAlreadyExistsException ex) { - log.warn("Duplicate property name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + private ApiExceptionHandler() { + } + + /// Handles domain exception when entity templates are not found. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 + /// status + /// with business-meaningful error message for API consumers. + @ExceptionHandler(EntityTemplateNotFoundException.class) + public ResponseEntity handleTemplateNotFoundException( + EntityTemplateNotFoundException ex) { + log.warn("Template not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles domain exception when entity templates already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP + /// 409 + /// status indicating business rule conflict for duplicate identifiers. + @ExceptionHandler(EntityTemplateAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateAlreadyExistsException( + EntityTemplateAlreadyExistsException ex) { + log.warn("Entity template already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity template names already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to + /// HTTP 409 + /// status indicating business rule conflict for duplicate template names. + @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateNameAlreadyExistsException( + EntityTemplateNameAlreadyExistsException ex) { + log.warn("Entity template name already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when attempting to change an entity template + /// identifier. + /// + /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException + /// to HTTP 400 + /// status indicating validation error for immutable identifier field. + @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) + public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + EntityTemplateIdentifierCannotChangeException ex) { + log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception for wrong entity template property rules. + /// + /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to + /// HTTP 400 + /// status indicating validation error for wrong property rules. + @ExceptionHandler(PropertyDefinitionRulesConflictException.class) + public ResponseEntity handleWrongPropertyRulesException( + PropertyDefinitionRulesConflictException ex) { + log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception when property names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate property names. + @ExceptionHandler(PropertyNameAlreadyExistsException.class) + public ResponseEntity handlePropertyNameAlreadyExistsException( + PropertyNameAlreadyExistsException ex) { + log.warn("Duplicate property name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate relation names. + @ExceptionHandler(RelationNameAlreadyExistsException.class) + public ResponseEntity handleRelationNameAlreadyExistsException( + RelationNameAlreadyExistsException ex) { + log.warn("Duplicate relation name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation references a non-existent target + /// template. + /// + /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 + /// status indicating validation error for missing target template. + @ExceptionHandler(TargetTemplateNotFoundException.class) + public ResponseEntity handleTargetTemplateNotFoundException( + TargetTemplateNotFoundException ex) { + log.warn("Target template not found: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when type changes are attempted. + /// + /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 + /// status indicating validation error for type changes. + @ExceptionHandler(PropertyTypeChangeException.class) + public ResponseEntity handleTypeChangeException(PropertyTypeChangeException ex) { + log.warn("Type change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation target template changes are + /// attempted. + /// + /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP + /// 400 + /// status indicating validation error for immutable target template field. + @ExceptionHandler(RelationTargetTemplateChangeException.class) + public ResponseEntity handleRelationTargetTemplateChangeException( + RelationTargetTemplateChangeException ex) { + log.warn("Relation target template change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation's target template identifier is the + /// template itself. + /// + /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP + /// 400 + /// status indicating validation error for self-referential relations. + @ExceptionHandler(RelationCannotTargetItselfException.class) + public ResponseEntity handleRelationCannotTargetItselfException( + RelationCannotTargetItselfException ex) { + log.warn("Relation self-reference error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles Bean Validation constraint violations from domain model validation. + /// + /// **Error aggregation:** Combines multiple constraint violation messages into + /// single user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Validation constraint violation: {}", ex.getMessage()); + + String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles Spring MVC request body validation failures. + /// + /// **Field-level errors:** Extracts and aggregates field validation errors from + /// request body binding into comprehensive HTTP 400 error response. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + log.warn("Method argument validation error: {}", ex.getMessage()); + + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles JSON parsing and deserialization errors from request bodies. + /// + /// **User-friendly parsing:** Converts technical JSON parsing errors into + /// readable messages, especially for enum validation and format issues. + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex) { + log.warn("HTTP message not readable: {}", ex.getMessage()); + + String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities are not found. + /// + /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status + /// with specific entity context for API consumers. + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException( + EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status + /// with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException( + EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNotFound(NoHandlerFoundException e) { + return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); + } + + private String parseHttpMessageNotReadableError(String originalMessage) { + if (originalMessage == null) { + return "Invalid request body format"; } - /// Handles domain exception when relation names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate relation names. - @ExceptionHandler(RelationNameAlreadyExistsException.class) - public ResponseEntity handleRelationNameAlreadyExistsException( - RelationNameAlreadyExistsException ex) { - log.warn("Duplicate relation name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + if (originalMessage.contains("Cannot deserialize value")) { + return parseDeserializationError(originalMessage); + } else if (originalMessage.contains("Required request body is missing")) { + return "Request body is required"; + } else if (originalMessage.contains("JSON parse error")) { + return "Invalid JSON format in request body"; } - /// Handles domain exception when a relation references a non-existent target template. - /// - /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target template. - @ExceptionHandler(TargetTemplateNotFoundException.class) - public ResponseEntity handleTargetTemplateNotFoundException( - TargetTemplateNotFoundException ex) { - log.warn("Target template not found: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } + return "Invalid request body format"; + } - /// Handles domain exception when type changes are attempted. - /// - /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 - /// status indicating validation error for type changes. - @ExceptionHandler(PropertyTypeChangeException.class) - public ResponseEntity handleTypeChangeException( - PropertyTypeChangeException ex) { - log.warn("Type change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + private String parseDeserializationError(String originalMessage) { + if (originalMessage.contains("not one of the values accepted for Enum class")) { + return parseEnumDeserializationError(originalMessage); } + return parseTypeDeserializationError(originalMessage); + } - /// Handles domain exception when relation target template changes are attempted. - /// - /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP 400 - /// status indicating validation error for immutable target template field. - @ExceptionHandler(RelationTargetTemplateChangeException.class) - public ResponseEntity handleRelationTargetTemplateChangeException( - RelationTargetTemplateChangeException ex) { - log.warn("Relation target template change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when a relation's target template identifier is the template itself. - /// - /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP 400 - /// status indicating validation error for self-referential relations. - @ExceptionHandler(RelationCannotTargetItselfException.class) - public ResponseEntity handleRelationCannotTargetItselfException( - RelationCannotTargetItselfException ex) { - log.warn("Relation self-reference error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } + private String parseTypeDeserializationError(String originalMessage) { + String targetType = extractTargetType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - /// Handles Bean Validation constraint violations from domain model validation. - /// - /// **Error aggregation:** Combines multiple constraint violation messages into - /// single user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { - log.warn("Validation constraint violation: {}", ex.getMessage()); - - String errorMessage = ex.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + if (!targetType.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property, expected " + targetType; + } else if (!targetType.isEmpty()) { + return "Invalid type: expected " + targetType; } - - /// Handles Spring MVC request body validation failures. - /// - /// **Field-level errors:** Extracts and aggregates field validation errors from - /// request body binding into comprehensive HTTP 400 error response. - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { - log.warn("Method argument validation error: {}", ex.getMessage()); - - String errorMessage = ex.getBindingResult().getFieldErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + return "Cannot deserialize request body property"; + } + + private String extractTargetType(String message) { + Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); + Matcher matcher = typePattern.matcher(message); + if (matcher.find()) { + String fullType = matcher.group(1); + return fullType.substring(fullType.lastIndexOf('.') + 1); } - - /// Handles JSON parsing and deserialization errors from request bodies. - /// - /// **User-friendly parsing:** Converts technical JSON parsing errors into - /// readable messages, especially for enum validation and format issues. - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { - log.warn("HTTP message not readable: {}", ex.getMessage()); - - String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + return ""; + } + + private String extractInvalidValueFromString(String message) { + Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); + Matcher matcher = valuePattern.matcher(message); + if (matcher.find()) { + return matcher.group(1); } + return ""; + } + private String parseEnumDeserializationError(String originalMessage) { + String enumTypeName = getPropertyNameFromEnumType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - /// Handles domain exception when entities are not found. - /// - /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status - /// with specific entity context for API consumers. - @ExceptionHandler(EntityNotFoundException.class) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; + } else if (!enumTypeName.isEmpty()) { + return "Invalid value for property '" + enumTypeName + "'"; } + return "Invalid enum value in request body"; + } - /// Handles domain exception when entities already exist. - /// - /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate entities. - @ExceptionHandler(EntityAlreadyExistsException.class) - public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { - log.warn("Entity already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } + private static final Map ENUM_TYPE_TO_PROPERTY = Map.of("PropertyType", "type", + "PropertyFormat", "format"); - /// Handles domain exception when entity validation fails. - /// - /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated - /// validation error messages for client correction. - @ExceptionHandler(EntityValidationException.class) - public ResponseEntity handleEntityValidationException(EntityValidationException ex) { - log.warn("Entity validation failed: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - - private String parseHttpMessageNotReadableError(String originalMessage) { - if (originalMessage == null) { - return "Invalid request body format"; - } - - if (originalMessage.contains("Cannot deserialize value")) { - return parseDeserializationError(originalMessage); - } else if (originalMessage.contains("Required request body is missing")) { - return "Request body is required"; - } else if (originalMessage.contains("JSON parse error")) { - return "Invalid JSON format in request body"; - } - - return "Invalid request body format"; - } - - private String parseDeserializationError(String originalMessage) { - if (originalMessage.contains("not one of the values accepted for Enum class")) { - return parseEnumDeserializationError(originalMessage); - } - return parseTypeDeserializationError(originalMessage); - } - - private String parseTypeDeserializationError(String originalMessage) { - String targetType = extractTargetType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!targetType.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property, expected " + targetType; - } else if (!targetType.isEmpty()) { - return "Invalid type: expected " + targetType; - } - return "Cannot deserialize request body property"; - } - - private String extractTargetType(String message) { - Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); - Matcher matcher = typePattern.matcher(message); - if (matcher.find()) { - String fullType = matcher.group(1); - return fullType.substring(fullType.lastIndexOf('.') + 1); - } - return ""; - } - - private String extractInvalidValueFromString(String message) { - Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); - Matcher matcher = valuePattern.matcher(message); - if (matcher.find()) { - return matcher.group(1); - } - return ""; - } - - private String parseEnumDeserializationError(String originalMessage) { - String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; - } else if (!enumTypeName.isEmpty()) { - return "Invalid value for property '" + enumTypeName + "'"; - } - return "Invalid enum value in request body"; - } - - private static final Map ENUM_TYPE_TO_PROPERTY = Map.of( - "PropertyType", "type", - "PropertyFormat", "format"); - - private static final Pattern ENUM_CLASS_PATTERN = Pattern.compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - - private String getPropertyNameFromEnumType(String message) { - Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); - if (matcher.find()) { - String enumType = matcher.group(1); - return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); - } - return ""; - } - - /// Handles all unexpected exceptions as safety fallback. - /// - /// **Security consideration:** Returns generic error message to prevent information - /// leakage while logging full exception details for internal debugging. - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - - String errorMessage = "An unexpected error occurred. Please try again later."; - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); - } - - private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { - return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); - } + private static final Pattern ENUM_CLASS_PATTERN = Pattern + .compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - @Getter - @AllArgsConstructor - @NoArgsConstructor(force = true) - public static class ErrorResponse { - private String error; - private String errorDescription; + private String getPropertyNameFromEnumType(String message) { + Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); + if (matcher.find()) { + String enumType = matcher.group(1); + return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); } + return ""; + } + + /// Handles all unexpected exceptions as safety fallback. + /// + /// **Security consideration:** Returns generic error message to prevent + /// information + /// leakage while logging full exception details for internal debugging. + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + + String errorMessage = "An unexpected error occurred. Please try again later."; + return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); + } + + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, + String errorMessage) { + return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor(force = true) + public static class ErrorResponse { + private String error; + private String errorDescription; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 5548ec0..9aed43d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -12,6 +11,8 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; +import lombok.RequiredArgsConstructor; + /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -31,39 +32,26 @@ @RequiredArgsConstructor public class EntityDtoInMapper { - /// Converts an entity creation request DTO to a domain entity. - /// - /// @param entityDtoIn the entity creation request payload - /// @param entityTemplateIdentifier the target template identifier - /// @return the mapped domain entity with audit fields populated - public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { - - List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() - : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> new Property( - null, - entry.getKey(), - entry.getValue() - )) - .toList(); - - List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() - : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, - relDto.getTargetEntityIdentifiers() - )) - .toList(); - - return new Entity( - null, - entityTemplateIdentifier, - entityDtoIn.getName(), - entityDtoIn.getIdentifier(), - properties, - relations - ); - } + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated + public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { + + List properties = entityDtoIn.getProperties() == null + ? Collections.emptyList() + : entityDtoIn.getProperties().entrySet().stream() + .map((Map.Entry entry) -> new Property(null, entry.getKey(), + entry.getValue())) + .toList(); + + List relations = entityDtoIn.getRelations() == null + ? Collections.emptyList() + : entityDtoIn.getRelations().stream().map(relDto -> new Relation(null, relDto.getName(), + null, relDto.getTargetEntityIdentifiers())).toList(); + + return new Entity(null, entityTemplateIdentifier, entityDtoIn.getName(), + entityDtoIn.getIdentifier(), properties, relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 755d424..3fab5fd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -49,269 +49,258 @@ @RequiredArgsConstructor public class EntityDtoOutMapper { - private final EntityTemplateService entityTemplateService; - private final EntityService entityService; - private final RelationService relationService; - - /// Maps a single domain entity to API DTO using template-based conversion. - /// - /// **Infrastructure mapping:** Resolves entity template dynamically and performs - /// complete domain-to-DTO transformation including properties and relationships. - /// - /// @param entity domain entity to convert for API response - /// @return fully mapped entity DTO with resolved template metadata - public EntityDtoOut fromEntity(Entity entity) { - EntityTemplate entityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entity.templateIdentifier()); - return fromEntityUsingEntityTemplate(entity, entityTemplate); - } - - /// Maps paginated domain entities to API DTOs with optimized bulk operations. - /// - /// **Performance optimization:** Batches template resolution and relationship lookups - /// to minimize database queries. Builds summary maps for efficient relationship - /// resolution across the entire page. - /// - /// @param entities paginated domain entities from repository layer - /// @param entityTemplateIdentifier template identifier for batch template resolution - /// @return paginated API DTOs with complete relationship data - public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { - - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); - - EntityTemplate pageEntityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, pageEntityTemplate, - pageEntitiesSummaries, relationTargetOwnershipsMap)); - } - - - /// Maps a single entity to its DTO using the provided entity template. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { - Map props = mapPropertiesDto(entity, entityTemplate); - - List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); - Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap(allTargetIdentifiers); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaryMap); - Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( - entity); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relatedEntitiesByTargetSummaryMap); - - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); - } - - /// Maps a single entity to its DTO using pre-built summary and - /// relation-as-target maps. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { - - Map props = mapPropertiesDto(entity, entityTemplate); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relationTargetOwnershipsMap); - - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); - } - - /// Maps the properties of an entity to a map of property names to typed values, - /// using the entity template for type conversion. - /// - /// @param entity the entity whose properties to map - /// @param entityTemplate the template for property type mapping - /// @return a map of property names to typed values - private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { - - if (entity.properties() == null) { - return Collections.emptyMap(); - } - - Map propertiesDefinitions = entityTemplate.propertiesDefinitions().stream() - .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); - - return entity.properties().stream() - .filter(prop -> prop.value() != null) - .collect(Collectors.toMap( - Property::name, - prop -> { - PropertyDefinition def = propertiesDefinitions.get(prop.name()); - Object rawValue = prop.value(); - if (def == null || rawValue == null) { - return rawValue; - } - String stringValue = String.valueOf(rawValue); - PropertyType type = def.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(stringValue); - } catch (NumberFormatException _) { - return null; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(stringValue); - } - return stringValue; - })); - } - - /// Maps the relations of an entity to a map of relation names to lists of target - /// entity summaries. - /// - /// @param entity the entity whose relations to map - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @return a map of relation names to lists of target entity summaries - private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { - return entity.relations() == null - ? Collections.emptyMap() - : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() - .map(relatedEntitiesSummaries::get) - .filter(Objects::nonNull), - Collectors.toList()))); - } - - /// - /// Maps the relations-as-target for an entity to a map of relation names to - /// lists of source entity summaries. - /// - /// @param entity the entity whose relations-as-target to - /// map - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return a map of relation names to lists of source entity summaries - private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { - List relationAsTargetSummaries = relationTargetOwnershipsMap - .get(entity.identifier()); - if (relationAsTargetSummaries == null) { - return Collections.emptyMap(); - } - - return relationAsTargetSummaries.stream() - .collect(Collectors.groupingBy( - RelationAsTargetSummary::relationName, - Collectors.mapping( - r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), - Collectors.toList()))); + private final EntityTemplateService entityTemplateService; + private final EntityService entityService; + private final RelationService relationService; + + /// Maps a single domain entity to API DTO using template-based conversion. + /// + /// **Infrastructure mapping:** Resolves entity template dynamically and + /// performs + /// complete domain-to-DTO transformation including properties and + /// relationships. + /// + /// @param entity domain entity to convert for API response + /// @return fully mapped entity DTO with resolved template metadata + public EntityDtoOut fromEntity(Entity entity) { + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + return fromEntityUsingEntityTemplate(entity, entityTemplate); + } + + /// Maps paginated domain entities to API DTOs with optimized bulk operations. + /// + /// **Performance optimization:** Batches template resolution and relationship + /// lookups + /// to minimize database queries. Builds summary maps for efficient relationship + /// resolution across the entire page. + /// + /// @param entities paginated domain entities from repository layer + /// @param entityTemplateIdentifier template identifier for batch template + /// resolution + /// @return paginated API DTOs with complete relationship data + public Page fromEntitiesPageToDtoPage(Page entities, + String entityTemplateIdentifier) { + + Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( + entities); + Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( + entities); + + EntityTemplate pageEntityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, + pageEntityTemplate, pageEntitiesSummaries, relationTargetOwnershipsMap)); + } + + /// Maps a single entity to its DTO using the provided entity template. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + Map props = mapPropertiesDto(entity, entityTemplate); + + List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); + Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap( + allTargetIdentifiers); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaryMap); + Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( + entity); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relatedEntitiesByTargetSummaryMap); + + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } + + /// Maps a single entity to its DTO using pre-built summary and + /// relation-as-target maps. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, + EntityTemplate entityTemplate, Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { + + Map props = mapPropertiesDto(entity, entityTemplate); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaries); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relationTargetOwnershipsMap); + + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } + + /// Maps the properties of an entity to a map of property names to typed values, + /// using the entity template for type conversion. + /// + /// @param entity the entity whose properties to map + /// @param entityTemplate the template for property type mapping + /// @return a map of property names to typed values + private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { + + if (entity.properties() == null) { + return Collections.emptyMap(); } - /// Builds a map of relation target ownerships for a list of entities, grouping - /// by target entity identifier. - /// - /// @param entitiesPage the list of entities to analyze - /// @return a map from target entity identifier to list of relation-as-target summaries - private Map> buildRelationsAsTargetSummaryMapByPage( - Page entitiesPage) { - if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { - return Collections.emptyMap(); - } - List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) - .filter(Objects::nonNull).toList(); - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + Map propertiesDefinitions = entityTemplate.propertiesDefinitions() + .stream().collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); + + return entity.properties().stream().filter(prop -> prop.value() != null) + .collect(Collectors.toMap(Property::name, prop -> { + PropertyDefinition def = propertiesDefinitions.get(prop.name()); + Object rawValue = prop.value(); + if (def == null || rawValue == null) { + return rawValue; + } + String stringValue = String.valueOf(rawValue); + PropertyType type = def.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(stringValue); + } catch (NumberFormatException _) { + return null; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(stringValue); + } + return stringValue; + })); + } + + /// Maps the relations of an entity to a map of relation names to lists of + /// target + /// entity summaries. + /// + /// @param entity the entity whose relations to map + /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @return a map of relation names to lists of target entity summaries + private Map> mapRelationsDto(Entity entity, + Map relatedEntitiesSummaries) { + return entity.relations() == null + ? Collections.emptyMap() + : entity.relations().stream().collect(Collectors.groupingBy(Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .map(relatedEntitiesSummaries::get).filter(Objects::nonNull), + Collectors.toList()))); + } + + /// + /// Maps the relations-as-target for an entity to a map of relation names to + /// lists of source entity summaries. + /// + /// @param entity the entity whose relations-as-target to + /// map + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return a map of relation names to lists of source entity summaries + private Map> mapRelationsAsTargetDto(Entity entity, + Map> relationTargetOwnershipsMap) { + List relationAsTargetSummaries = relationTargetOwnershipsMap + .get(entity.identifier()); + if (relationAsTargetSummaries == null) { + return Collections.emptyMap(); } - /// - /// Builds a map of relation target ownerships for a single entity, grouping by - /// target entity identifier. - /// - /// @param entity the entity to analyze - /// @return a map from target entity identifier to list of relation-as-target - /// summaries - private Map> buildRelationsAsTargetSummaryMapByEntity(Entity entity) { - if (entity == null || entity.identifier() == null) { - return Collections.emptyMap(); - } - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + return relationAsTargetSummaries.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::relationName, + Collectors.mapping( + r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), + Collectors.toList()))); + } + + /// Builds a map of relation target ownerships for a list of entities, grouping + /// by target entity identifier. + /// + /// @param entitiesPage the list of entities to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByPage( + Page entitiesPage) { + if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { + return Collections.emptyMap(); } - - /// Gets all unique target entity identifiers from the relations of a single - /// entity. - /// - /// @param entity the entity to analyze - /// @return a list of unique target entity identifiers - private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { - return entity.relations() == null - ? Collections.emptyList() - : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); - } - - /// - /// Gets all unique target entity identifiers from the relations of all entities - /// in a page. - /// - /// @param entities the page of entities to analyze - /// @return a list of unique target entity identifiers - private List getUniqueTargetIdentifiersInPage(Page entities) { - return new ArrayList<>(entities.stream() - .flatMap(entity -> entity.relations() == null - ? Stream.empty() - : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) - .collect(Collectors.toSet())); - - } - - /// Builds a map of entity summaries for all unique target identifiers in a page - /// of entities. - /// - /// @param entities the page of entities - /// @return a map from entity identifier to summary DTO - private Map buildRelatedEntitiesSummaryMapByPage(Page entities) { - return buildEntitiesSummariesMap( - getUniqueTargetIdentifiersInPage(entities)); - } - - /// Builds a map of entity summaries for a list of target identifiers. - /// - /// @param targetIdentifiers the list of target entity identifiers - /// @return a map from entity identifier to summary DTO - private Map buildEntitiesSummariesMap(List targetIdentifiers) { - return targetIdentifiers.isEmpty() - ? Collections.emptyMap() - : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) + .filter(Objects::nonNull).toList(); + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } + + /// + /// Builds a map of relation target ownerships for a single entity, grouping by + /// target entity identifier. + /// + /// @param entity the entity to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByEntity( + Entity entity) { + if (entity == null || entity.identifier() == null) { + return Collections.emptyMap(); } + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } + + /// Gets all unique target entity identifiers from the relations of a single + /// entity. + /// + /// @param entity the entity to analyze + /// @return a list of unique target entity identifiers + private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { + return entity.relations() == null + ? Collections.emptyList() + : new ArrayList<>(entity.relations().stream() + .flatMap(rel -> rel.targetEntityIdentifiers().stream()).collect(Collectors.toSet())); + } + + /// + /// Gets all unique target entity identifiers from the relations of all entities + /// in a page. + /// + /// @param entities the page of entities to analyze + /// @return a list of unique target entity identifiers + private List getUniqueTargetIdentifiersInPage(Page entities) { + return new ArrayList<>(entities.stream() + .flatMap(entity -> entity.relations() == null + ? Stream.empty() + : entity.relations().stream().flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .collect(Collectors.toSet())); + + } + + /// Builds a map of entity summaries for all unique target identifiers in a page + /// of entities. + /// + /// @param entities the page of entities + /// @return a map from entity identifier to summary DTO + private Map buildRelatedEntitiesSummaryMapByPage( + Page entities) { + return buildEntitiesSummariesMap(getUniqueTargetIdentifiersInPage(entities)); + } + + /// Builds a map of entity summaries for a list of target identifiers. + /// + /// @param targetIdentifiers the list of target entity identifiers + /// @return a map from entity identifier to summary DTO + private Map buildEntitiesSummariesMap(List targetIdentifiers) { + return targetIdentifiers.isEmpty() + ? Collections.emptyMap() + : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers).stream() + .collect(Collectors.toMap(EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index 8b90f8b..fd96646 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -32,149 +32,150 @@ /// otherwise be emitted twice when both sides of a relation are traversed. public final class EntityGraphFlatDtoOutMapper { - private EntityGraphFlatDtoOutMapper() { - // Utility class — not instantiable + private EntityGraphFlatDtoOutMapper() { + // Utility class — not instantiable + } + + /// Groups mutable traversal accumulators to stay within the method-parameter + /// limit + /// and keep the traversal signature readable. + private record TraversalState(SequencedSet nodes, + List edges, Set visitedNodeIds, + Set emittedEdgeSignatures, AtomicInteger edgeCounter) { + } + + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. + /// + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set + /// are emitted, + /// and nodes not referenced by any remaining edge are pruned; + /// an empty set means no filter — all edge types and nodes are emitted + /// @param propertyFilter when non-empty, only properties whose name is in this + /// set appear + /// in each node's `data` field; + /// an empty set means no filter — all properties are included + /// @return flat DTO with deduplicated nodes and directed edges + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, + Set propertyFilter) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); } - /// Groups mutable traversal accumulators to stay within the method-parameter limit - /// and keep the traversal signature readable. - private record TraversalState( - SequencedSet nodes, - List edges, - Set visitedNodeIds, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter) { + var state = new TraversalState(new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated + new ArrayList<>(), // edges + new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs + new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges + new AtomicInteger(0)); // edgeCounter + + traverse(root, state, relationFilter, propertyFilter); + + // When a relation filter is active, prune nodes that are not connected to any + // remaining edge. Without this step, nodes reachable via non-filtered edges + // would + // appear in the node list despite having no visible edges. + List finalNodes; + if (relationFilter.isEmpty()) { + finalNodes = List.copyOf(state.nodes()); + } else { + // Collect all node IDs referenced by the filtered edges only. + // The root receives no special treatment: if it has no matching edges + // it is pruned just like any other disconnected node. + Set referencedNodeIds = new HashSet<>(); + for (var edge : state.edges()) { + referencedNodeIds.add(edge.source()); + referencedNodeIds.add(edge.target()); + } + finalNodes = state.nodes().stream().filter(n -> referencedNodeIds.contains(n.id())).toList(); } - /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. - /// - /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, - /// and nodes not referenced by any remaining edge are pruned; - /// an empty set means no filter — all edge types and nodes are emitted - /// @param propertyFilter when non-empty, only properties whose name is in this set appear - /// in each node's `data` field; - /// an empty set means no filter — all properties are included - /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, - Set propertyFilter) { - if (root == null) { - return new EntityGraphFlatDtoOut(List.of(), List.of()); - } - - var state = new TraversalState( - new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated - new ArrayList<>(), // edges - new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs - new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges - new AtomicInteger(0)); // edgeCounter - - traverse(root, state, relationFilter, propertyFilter); - - // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. Without this step, nodes reachable via non-filtered edges would - // appear in the node list despite having no visible edges. - List finalNodes; - if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(state.nodes()); - } else { - // Collect all node IDs referenced by the filtered edges only. - // The root receives no special treatment: if it has no matching edges - // it is pruned just like any other disconnected node. - Set referencedNodeIds = new HashSet<>(); - for (var edge : state.edges()) { - referencedNodeIds.add(edge.source()); - referencedNodeIds.add(edge.target()); - } - finalNodes = state.nodes().stream() - .filter(n -> referencedNodeIds.contains(n.id())) - .toList(); - } - - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); - } - - private static void traverse( - EntityGraphNode node, - TraversalState state, - Set relationFilter, - Set propertyFilter) { + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); + } - var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + private static void traverse(EntityGraphNode node, TraversalState state, + Set relationFilter, Set propertyFilter) { - // Skip this node if already visited to prevent infinite loops in cyclic graphs - if (!state.visitedNodeIds().add(nodeId)) { - return; - } + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); - state.nodes().add(new EntityGraphNodeFlatDtoOut( - nodeId, node.name(), node.templateIdentifier(), node.identifier(), - toDataMap(node, propertyFilter))); - - // Traverse outbound relations: emit edge from currentNode → target only when the - // relation type matches the filter (or no filter is active). Nodes are always - // traversed so that deeper nodes remain reachable regardless of edge visibility. - for (EntityGraphRelation relation : node.relations()) { - for (EntityGraphNode target : relation.targets()) { - var targetId = nodeId(target.templateIdentifier(), target.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, nodeId, targetId, relation.name()); - } - traverse(target, state, relationFilter, propertyFilter); - } - } + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!state.visitedNodeIds().add(nodeId)) { + return; + } - // Traverse inbound relations: emit edge from source → currentNode. - // This is essential when the root entity has no outbound relations and is only - // reachable as a target. Without this, traversal would stop at the root with no edges. - for (EntityGraphRelation relation : node.relationsAsTarget()) { - for (EntityGraphNode source : relation.targets()) { - var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, sourceId, nodeId, relation.name()); - } - traverse(source, state, relationFilter, propertyFilter); - } + state.nodes().add(new EntityGraphNodeFlatDtoOut(nodeId, node.name(), node.templateIdentifier(), + node.identifier(), toDataMap(node, propertyFilter))); + + // Traverse outbound relations: emit edge from currentNode → target only when + // the + // relation type matches the filter (or no filter is active). Nodes are always + // traversed so that deeper nodes remain reachable regardless of edge + // visibility. + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(state, nodeId, targetId, relation.name()); } + traverse(target, state, relationFilter, propertyFilter); + } } - /// Adds a directed edge only if it has not been emitted before, preventing duplicates - /// that arise when the same relation is encountered from both the source and the target - /// during depth-first traversal. - private static void addEdge( - TraversalState state, - String sourceId, - String targetId, - String label) { - - var signature = sourceId + "|" + targetId + "|" + label; - if (state.emittedEdgeSignatures().add(signature)) { - state.edges().add(new EntityGraphEdgeDtoOut( - "e" + state.edgeCounter().incrementAndGet(), sourceId, targetId, label)); + // Traverse inbound relations: emit edge from source → currentNode. + // This is essential when the root entity has no outbound relations and is only + // reachable as a target. Without this, traversal would stop at the root with no + // edges. + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(state, sourceId, nodeId, relation.name()); } + traverse(source, state, relationFilter, propertyFilter); + } } - - /// Builds the unique node identifier from the entity's composite key. - /// Format: "templateIdentifier:identifier" — mirrors EntityCompositeKey.toString(). - private static String nodeId(String templateIdentifier, String identifier) { - return templateIdentifier + ":" + identifier; + } + + /// Adds a directed edge only if it has not been emitted before, preventing + /// duplicates + /// that arise when the same relation is encountered from both the source and + /// the target + /// during depth-first traversal. + private static void addEdge(TraversalState state, String sourceId, String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut("e" + state.edgeCounter().incrementAndGet(), + sourceId, targetId, label)); } - - /// Converts a node's property list to a name→value map for the `data` field. - /// - /// When [propertyFilter] is non-empty, only entries whose name is contained in the - /// filter are included. Returns an empty map when there are no matching properties; - /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from - /// the JSON output. - /// - /// @param node the graph node whose properties are converted - /// @param propertyFilter when non-empty, restricts which properties appear in the map; - /// an empty set means all properties are included - private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { - var stream = node.properties().stream(); - if (!propertyFilter.isEmpty()) { - stream = stream.filter(p -> propertyFilter.contains(p.name())); - } - return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors + /// EntityCompositeKey.toString(). + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } + + /// Converts a node's property list to a name→value map for the `data` field. + /// + /// When [propertyFilter] is non-empty, only entries whose name is contained in + /// the + /// filter are included. Returns an empty map when there are no matching + /// properties; + /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted + /// from + /// the JSON output. + /// + /// @param node the graph node whose properties are converted + /// @param propertyFilter when non-empty, restricts which properties appear in + /// the map; + /// an empty set means all properties are included + private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { + var stream = node.properties().stream(); + if (!propertyFilter.isEmpty()) { + stream = stream.filter(p -> propertyFilter.contains(p.name())); } + return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java index 3dac279..d64da39 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java @@ -37,234 +37,191 @@ @Component public class EntityTemplateMapper { - /// - /// Converts an EntityTemplate input DTO to a domain entity. - /// This method maps all fields from the input DTO to create a new EntityTemplate - /// domain entity. - /// Nested collections (properties and relations) are recursively converted using - /// their - /// respective mapping methods. - /// - /// @param dto the input DTO to convert, may be null - /// @return the converted EntityTemplate domain entity, or null if input is null - public EntityTemplate fromDtoToEntityTemplate(EntityTemplateCreateDtoIn dto) { - if (dto == null || dto.getCommonFields() == null) { - return null; - } - - return new EntityTemplate( - null, - dto.getIdentifier(), - dto.getCommonFields().getName(), - dto.getCommonFields().getDescription(), - toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), - toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions()) - ); + /// + /// Converts an EntityTemplate input DTO to a domain entity. + /// This method maps all fields from the input DTO to create a new + /// EntityTemplate + /// domain entity. + /// Nested collections (properties and relations) are recursively converted + /// using + /// their + /// respective mapping methods. + /// + /// @param dto the input DTO to convert, may be null + /// @return the converted EntityTemplate domain entity, or null if input is null + public EntityTemplate fromDtoToEntityTemplate(EntityTemplateCreateDtoIn dto) { + if (dto == null || dto.getCommonFields() == null) { + return null; } - /// - /// Converts an EntityTemplate PUT input DTO to a domain entity. - /// This method maps all fields from the PUT DTO to create a new EntityTemplate - /// domain entity, using the provided identifier from the path parameter. - /// Nested collections (properties and relations) are recursively converted using - /// their respective mapping methods. - /// - /// @param identifier the entity identifier from the path parameter - /// @param dto the input DTO to convert, may be null - /// @return the converted EntityTemplate domain entity, or null if input is null - public EntityTemplate fromPutDtoToEntityTemplate(String identifier, EntityTemplateUpdateDtoIn dto) { - if (dto == null || dto.getCommonFields() == null) { - return null; - } - - return new EntityTemplate( - null, - identifier, - dto.getCommonFields().getName(), - dto.getCommonFields().getDescription(), - toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), - toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions()) - ); + return new EntityTemplate(null, dto.getIdentifier(), dto.getCommonFields().getName(), + dto.getCommonFields().getDescription(), + toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), + toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions())); + } + + /// + /// Converts an EntityTemplate PUT input DTO to a domain entity. + /// This method maps all fields from the PUT DTO to create a new EntityTemplate + /// domain entity, using the provided identifier from the path parameter. + /// Nested collections (properties and relations) are recursively converted + /// using + /// their respective mapping methods. + /// + /// @param identifier the entity identifier from the path parameter + /// @param dto the input DTO to convert, may be null + /// @return the converted EntityTemplate domain entity, or null if input is null + public EntityTemplate fromPutDtoToEntityTemplate(String identifier, + EntityTemplateUpdateDtoIn dto) { + if (dto == null || dto.getCommonFields() == null) { + return null; } - /// - /// Converts an EntityTemplate domain entity to an output DTO. - /// This method maps all fields from the domain entity to create a new output DTO - /// for API responses. - /// The conversion includes the entity's UUID ID and all nested collections are - /// recursively - /// converted to their respective DTO representations. - /// - /// @param entity the domain entity to convert, may be null - /// @return the converted EntityTemplateDtoOut, or null if input is null - public EntityTemplateDtoOut fromEntityTemplatetoDto(EntityTemplate entity) { - if (entity == null) { - return null; - } - - return EntityTemplateDtoOut.builder() - .identifier(entity.identifier()) - .name(entity.name()) - .description(entity.description()) - .propertiesDefinitions(toPropertyDefinitionDtos(entity.propertiesDefinitions())) - .relationsDefinitions(toRelationDefinitionDtos(entity.relationsDefinitions())) - .build(); + return new EntityTemplate(null, identifier, dto.getCommonFields().getName(), + dto.getCommonFields().getDescription(), + toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), + toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions())); + } + + /// + /// Converts an EntityTemplate domain entity to an output DTO. + /// This method maps all fields from the domain entity to create a new output + /// DTO + /// for API responses. + /// The conversion includes the entity's UUID ID and all nested collections are + /// recursively + /// converted to their respective DTO representations. + /// + /// @param entity the domain entity to convert, may be null + /// @return the converted EntityTemplateDtoOut, or null if input is null + public EntityTemplateDtoOut fromEntityTemplatetoDto(EntityTemplate entity) { + if (entity == null) { + return null; } - /// - /// Converts a list of EntityTemplate domain entities to a list of output DTOs. - /// This is a convenience method for bulk conversion operations, particularly - /// useful - /// for paginated results and list endpoints. - ///

- /// - /// @param entities the list of domain entities to convert, may be null - /// @return a list of converted EntityTemplateDtoOut objects, empty list if input - /// is null - public List fromEntityTemplatesToDtos(List entities) { - if (entities == null) { - return List.of(); - } - return entities.stream() - .map(this::fromEntityTemplatetoDto) - .toList(); + return EntityTemplateDtoOut.builder().identifier(entity.identifier()).name(entity.name()) + .description(entity.description()) + .propertiesDefinitions(toPropertyDefinitionDtos(entity.propertiesDefinitions())) + .relationsDefinitions(toRelationDefinitionDtos(entity.relationsDefinitions())).build(); + } + + /// + /// Converts a list of EntityTemplate domain entities to a list of output DTOs. + /// This is a convenience method for bulk conversion operations, particularly + /// useful + /// for paginated results and list endpoints. + ///

+ /// + /// @param entities the list of domain entities to convert, may be null + /// @return a list of converted EntityTemplateDtoOut objects, empty list if + /// input + /// is null + public List fromEntityTemplatesToDtos(List entities) { + if (entities == null) { + return List.of(); + } + return entities.stream().map(this::fromEntityTemplatetoDto).toList(); + } + + /// + /// Converts a PropertyDefinition input DTO to a domain entity. + /// + /// @param dto the input DTO to convert, may be null + /// @return the converted PropertyDefinition domain entity, or null if input is + /// null + public PropertyDefinition toToPropertyDefinition(PropertyDefinitionDtoIn dto) { + if (dto == null) { + return null; } - /// - /// Converts a PropertyDefinition input DTO to a domain entity. - /// - /// @param dto the input DTO to convert, may be null - /// @return the converted PropertyDefinition domain entity, or null if input is - /// null - public PropertyDefinition toToPropertyDefinition(PropertyDefinitionDtoIn dto) { - if (dto == null) { - return null; - } - - return new PropertyDefinition( - null, - dto.getName(), - dto.getDescription(), - dto.getType(), - dto.isRequired(), - toPropertyRules(dto.getRules()) - ); + return new PropertyDefinition(null, dto.getName(), dto.getDescription(), dto.getType(), + dto.isRequired(), toPropertyRules(dto.getRules())); + } + + /// + /// Converts a PropertyDefinition domain entity to an output DTO. + /// + /// @param entity the domain entity to convert, may be null + /// @return the converted PropertyDefinitionDtoOut, or null if input is null + public PropertyDefinitionDtoOut toDto(PropertyDefinition entity) { + if (entity == null) { + return null; } - /// - /// Converts a PropertyDefinition domain entity to an output DTO. - /// - /// @param entity the domain entity to convert, may be null - /// @return the converted PropertyDefinitionDtoOut, or null if input is null - public PropertyDefinitionDtoOut toDto(PropertyDefinition entity) { - if (entity == null) { - return null; - } + return PropertyDefinitionDtoOut.builder().name(entity.name()).description(entity.description()) + .type(entity.type()).required(entity.required()).rules(toDto(entity.rules())).build(); + } - return PropertyDefinitionDtoOut.builder() - .name(entity.name()) - .description(entity.description()) - .type(entity.type()) - .required(entity.required()) - .rules(toDto(entity.rules())) - .build(); + public List toPropertyDefinitionEntities(List dtos) { + if (dtos == null) { + return List.of(); } + return dtos.stream().map(this::toToPropertyDefinition).toList(); + } - public List toPropertyDefinitionEntities(List dtos) { - if (dtos == null) { - return List.of(); - } - return dtos.stream() - .map(this::toToPropertyDefinition) - .toList(); + public List toPropertyDefinitionDtos( + List entities) { + if (entities == null) { + return List.of(); } + return entities.stream().map(this::toDto).toList(); + } - public List toPropertyDefinitionDtos(List entities) { - if (entities == null) { - return List.of(); - } - return entities.stream() - .map(this::toDto) - .toList(); + public PropertyRules toPropertyRules(PropertyRulesDtoIn dto) { + if (dto == null) { + return null; } - public PropertyRules toPropertyRules(PropertyRulesDtoIn dto) { - if (dto == null) { - return null; - } - - return new PropertyRules( - null, - dto.getFormat(), - dto.getEnumValues() != null - ? List.of(dto.getEnumValues()).stream().map(String::toUpperCase).toList() - : null, - dto.getRegex(), - dto.getMaxLength(), - dto.getMinLength(), - dto.getMaxValue(), - dto.getMinValue() - ); + return new PropertyRules(null, dto.getFormat(), + dto.getEnumValues() != null + ? List.of(dto.getEnumValues()).stream().map(String::toUpperCase).toList() + : null, + dto.getRegex(), dto.getMaxLength(), dto.getMinLength(), dto.getMaxValue(), + dto.getMinValue()); + } + + public PropertyRulesDtoOut toDto(PropertyRules entity) { + if (entity == null) { + return null; } - public PropertyRulesDtoOut toDto(PropertyRules entity) { - if (entity == null) { - return null; - } + return PropertyRulesDtoOut.builder().format(entity.format()) + .enumValues(entity.enumValues() != null ? entity.enumValues().toArray(new String[0]) : null) + .regex(entity.regex()).maxLength(entity.maxLength()).minLength(entity.minLength()) + .maxValue(entity.maxValue()).minValue(entity.minValue()).build(); + } - return PropertyRulesDtoOut.builder() - .format(entity.format()) - .enumValues(entity.enumValues() != null ? entity.enumValues().toArray(new String[0]) : null) - .regex(entity.regex()) - .maxLength(entity.maxLength()) - .minLength(entity.minLength()) - .maxValue(entity.maxValue()) - .minValue(entity.minValue()) - .build(); + public RelationDefinition toRelationDefinition(RelationDefinitionDtoIn dto) { + if (dto == null) { + return null; } - public RelationDefinition toRelationDefinition(RelationDefinitionDtoIn dto) { - if (dto == null) { - return null; - } + return new RelationDefinition(null, dto.getName(), dto.getTargetTemplateIdentifier(), + dto.isRequired(), dto.isToMany()); + } - return new RelationDefinition( - null, - dto.getName(), - dto.getTargetTemplateIdentifier(), - dto.isRequired(), - dto.isToMany() - ); + public RelationDefinitionDtoOut toDto(RelationDefinition entity) { + if (entity == null) { + return null; } - public RelationDefinitionDtoOut toDto(RelationDefinition entity) { - if (entity == null) { - return null; - } - - return RelationDefinitionDtoOut.builder() - .name(entity.name()) - .targetTemplateIdentifier(entity.targetTemplateIdentifier()) - .required(entity.required()) - .toMany(entity.toMany()) - .build(); - } + return RelationDefinitionDtoOut.builder().name(entity.name()) + .targetTemplateIdentifier(entity.targetTemplateIdentifier()).required(entity.required()) + .toMany(entity.toMany()).build(); + } - public List toRelationDefinitionEntities(List dtos) { - if (dtos == null) { - return List.of(); - } - return dtos.stream() - .map(this::toRelationDefinition) - .toList(); + public List toRelationDefinitionEntities(List dtos) { + if (dtos == null) { + return List.of(); } + return dtos.stream().map(this::toRelationDefinition).toList(); + } - public List toRelationDefinitionDtos(List entities) { - if (entities == null) { - return List.of(); - } - return entities.stream() - .map(this::toDto) - .toList(); + public List toRelationDefinitionDtos( + List entities) { + if (entities == null) { + return List.of(); } + return entities.stream().map(this::toDto).toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 2a877ee..faeb8e7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -21,54 +21,60 @@ @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { - private final JpaEntityRepository jpaEntityRepository; - private final EntityPersistenceMapper mapper; + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; - @Override - public Entity save(Entity entity) { - return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); - } + @Override + public Entity save(Entity entity) { + return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); + } - @Override - public Optional findById(UUID id) { - return jpaEntityRepository.findById(id).map(mapper::toDomain); - } + @Override + public Optional findById(UUID id) { + return jpaEntityRepository.findById(id).map(mapper::toDomain); + } - @Override - public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier) { - return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) - .map(mapper::toDomain); - } + @Override + public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier) { + return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) + .map(mapper::toDomain); + } - @Override - public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { - return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) - .map(mapper::toDomain); - } + @Override + public Optional findByTemplateIdentifierAndName(String templateIdentifier, + String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } - @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return pageableEntity.map(mapper::toDomain); - } + @Override + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return pageableEntity.map(mapper::toDomain); + } - @Override - public List findByIdentifierIn(List identifiers) { - return jpaEntityRepository.findByIdentifierIn(identifiers); - } + @Override + public List findByIdentifierIn(List identifiers) { + return jpaEntityRepository.findByIdentifierIn(identifiers); + } - @Override - public List findByRelationIdIn(List relationIds) { - return jpaEntityRepository.findByRelationIdIn(relationIds); - } + @Override + public List findByRelationIdIn(List relationIds) { + return jpaEntityRepository.findByRelationIdIn(relationIds); + } - @Override - public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames) { - jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, propertyNames); - } + @Override + public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames) { + jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, + propertyNames); + } - @Override - public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { - jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); - } + @Override + public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames) { + jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + relationNames); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index d48828c..c84c1ac 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -31,49 +31,44 @@ @RequiredArgsConstructor public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { - private final JpaEntityRepository jpaEntityRepository; - private final EntityPersistenceMapper mapper; + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; - @Override - @Transactional(readOnly = true) - public Map findEntityGraph( - String templateIdentifier, - String entityIdentifier, - int depth, - boolean includeProperties) { - // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE. - // The CTE always traverses ALL relation types to discover all reachable nodes. - // Relation name filtering is applied at the service level when building edges, - // so nodes reachable via any path are included even if the filter only matches - // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). - List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( - templateIdentifier, entityIdentifier, depth); + @Override + @Transactional(readOnly = true) + public Map findEntityGraph(String templateIdentifier, + String entityIdentifier, int depth, boolean includeProperties) { + // Step 1: collect all (identifier, template_identifier) pairs via recursive + // CTE. + // The CTE always traverses ALL relation types to discover all reachable nodes. + // Relation name filtering is applied at the service level when building edges, + // so nodes reachable via any path are included even if the filter only matches + // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers(templateIdentifier, + entityIdentifier, depth); - if (graphPairs.isEmpty()) { - return Map.of(); - } - - // Step 2: extract unique identifiers for batch loading - List identifiers = graphPairs.stream() - .map(pair -> (String) pair[0]) - .distinct() - .toList(); + if (graphPairs.isEmpty()) { + return Map.of(); + } - // Step 3: batch-load entities with relations, then optionally properties in a separate - // query. Properties are skipped when not requested to avoid the extra round-trip and - // keep payloads lean. The two-query split also avoids Hibernate's MultipleBagFetchException. - List jpaEntities = - jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); - if (includeProperties) { - jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); - } + // Step 2: extract unique identifiers for batch loading + List identifiers = graphPairs.stream().map(pair -> (String) pair[0]).distinct() + .toList(); - // Step 4: map to domain and key by composite key for O(1) lookup - return jpaEntities.stream() - .map(mapper::toDomain) - .collect(Collectors.toMap( - e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), - Function.identity() - )); + // Step 3: batch-load entities with relations, then optionally properties in a + // separate + // query. Properties are skipped when not requested to avoid the extra + // round-trip and + // keep payloads lean. The two-query split also avoids Hibernate's + // MultipleBagFetchException. + List jpaEntities = jpaEntityRepository + .findAllByIdentifierInWithRelations(identifiers); + if (includeProperties) { + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); } + + // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain).collect(Collectors.toMap( + e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), Function.identity())); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java index 1a88a5e..af72cfd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java @@ -39,180 +39,172 @@ @Component @RequiredArgsConstructor public class PostgresEntityTemplateAdapter implements EntityTemplateRepositoryPort { -/// - Entity graphs fetch properties and relations in single query -/// - Bulk operations minimize database round trips -/// - Lazy loading configured appropriately for relationship navigation - - private final JpaEntityTemplateRepository jpaEntityTemplateRepository; - private final EntityTemplatePersistenceMapper mapper; - - @Override - public Optional findByIdentifier(String templateIdentifier) { - return jpaEntityTemplateRepository.findByIdentifier(templateIdentifier).map(mapper::toDomain); + /// - Entity graphs fetch properties and relations in single query + /// - Bulk operations minimize database round trips + /// - Lazy loading configured appropriately for relationship navigation + + private final JpaEntityTemplateRepository jpaEntityTemplateRepository; + private final EntityTemplatePersistenceMapper mapper; + + @Override + public Optional findByIdentifier(String templateIdentifier) { + return jpaEntityTemplateRepository.findByIdentifier(templateIdentifier).map(mapper::toDomain); + } + + @Override + public Optional findById(UUID id) { + return jpaEntityTemplateRepository.findById(id).map(mapper::toDomain); + } + + @Override + public Page findAll(Pageable pageable) { + return jpaEntityTemplateRepository.findAll(pageable).map(mapper::toDomain); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaEntityTemplateRepository.existsByIdentifier(identifier); + } + + @Override + public boolean existsByName(String name) { + return jpaEntityTemplateRepository.existsByName(name); + } + + @Override + public EntityTemplate save(EntityTemplate entityTemplate) { + EntityTemplateJpaEntity jpaEntity; + if (entityTemplate.id() != null) { + // Update: fetch the managed JPA entity and merge in-place + jpaEntity = jpaEntityTemplateRepository.findById(entityTemplate.id()) + .orElseGet(() -> mapper.toJpa(entityTemplate)); + mergeIntoExisting(jpaEntity, entityTemplate); + } else { + jpaEntity = mapper.toJpa(entityTemplate); } - - @Override - public Optional findById(UUID id) { - return jpaEntityTemplateRepository.findById(id).map(mapper::toDomain); + return mapper.toDomain(jpaEntityTemplateRepository.save(jpaEntity)); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaEntityTemplateRepository.deleteByIdentifier(identifier); + } + + // ── Merge helpers to update a managed JPA entity from domain values ── + + private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { + jpa.setIdentifier(domain.identifier()); + jpa.setName(domain.name()); + jpa.setDescription(domain.description()); + mergePropertyDefinitions(jpa, domain); + mergeRelationDefinitions(jpa, domain); + } + + private void mergePropertyDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { + // Work on a mutable copy — getter returns an unmodifiable view + Set existing = new LinkedHashSet<>(jpa.getPropertiesDefinitions()); + + if (domain.propertiesDefinitions() == null) { + jpa.setPropertiesDefinitions(new LinkedHashSet<>()); + return; } - @Override - public Page findAll(Pageable pageable) { - return jpaEntityTemplateRepository.findAll(pageable).map(mapper::toDomain); + Map existingByName = existing.stream() + .collect(Collectors.toMap(PropertyDefinitionJpaEntity::getName, Function.identity())); + + Set updatedNames = domain.propertiesDefinitions().stream().map(p -> p.name()) + .collect(Collectors.toSet()); + + // Remove properties no longer present + existing.removeIf(p -> !updatedNames.contains(p.getName())); + + // Update existing or add new + for (var domProp : domain.propertiesDefinitions()) { + PropertyDefinitionJpaEntity ex = existingByName.get(domProp.name()); + if (ex != null) { + ex.setDescription(domProp.description()); + ex.setType(domProp.type()); + ex.setRequired(domProp.required()); + mergeRules(ex, domProp.rules()); + } else { + PropertyDefinitionJpaEntity newProp = PropertyDefinitionJpaEntity.builder().id(domProp.id()) + .name(domProp.name()).description(domProp.description()).type(domProp.type()) + .required(domProp.required()) + .rules(domProp.rules() != null ? toRulesJpa(domProp.rules()) : null).build(); + existing.add(newProp); + } } - @Override - public boolean existsByIdentifier(String identifier) { - return jpaEntityTemplateRepository.existsByIdentifier(identifier); - } + // Push the mutated copy back through the defensive setter + jpa.setPropertiesDefinitions(existing); + } - @Override - public boolean existsByName(String name) { - return jpaEntityTemplateRepository.existsByName(name); + private void mergeRules(PropertyDefinitionJpaEntity jpaProp, + com.decathlon.idp_core.domain.model.entity_template.PropertyRules domRules) { + if (domRules == null) { + // No rules in the updated domain – leave existing rules unchanged + return; } - - @Override - public EntityTemplate save(EntityTemplate entityTemplate) { - EntityTemplateJpaEntity jpaEntity; - if (entityTemplate.id() != null) { - // Update: fetch the managed JPA entity and merge in-place - jpaEntity = jpaEntityTemplateRepository.findById(entityTemplate.id()) - .orElseGet(() -> mapper.toJpa(entityTemplate)); - mergeIntoExisting(jpaEntity, entityTemplate); - } else { - jpaEntity = mapper.toJpa(entityTemplate); - } - return mapper.toDomain(jpaEntityTemplateRepository.save(jpaEntity)); + PropertyRulesJpaEntity ex = jpaProp.getRules(); + if (ex != null) { + // Update the managed entity in-place — Hibernate tracks the dirty fields + ex.setFormat(domRules.format()); + ex.setEnumValues( + domRules.enumValues() != null ? domRules.enumValues().toArray(new String[0]) : null); + ex.setRegex(domRules.regex()); + ex.setMaxLength(domRules.maxLength()); + ex.setMinLength(domRules.minLength()); + ex.setMaxValue(domRules.maxValue()); + ex.setMinValue(domRules.minValue()); + // Re-set the reference so Hibernate detects the association as dirty + jpaProp.setRules(ex); + } else { + jpaProp.setRules(toRulesJpa(domRules)); } - - @Override - public void deleteByIdentifier(String identifier) { - jpaEntityTemplateRepository.deleteByIdentifier(identifier); + } + + private PropertyRulesJpaEntity toRulesJpa( + com.decathlon.idp_core.domain.model.entity_template.PropertyRules d) { + return PropertyRulesJpaEntity.builder().id(d.id()).format(d.format()) + .enumValues(d.enumValues() != null ? d.enumValues().toArray(new String[0]) : null) + .regex(d.regex()).maxLength(d.maxLength()).minLength(d.minLength()).maxValue(d.maxValue()) + .minValue(d.minValue()).build(); + } + + private void mergeRelationDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { + // Work on a mutable copy — getter returns an unmodifiable view + Set existing = new LinkedHashSet<>(jpa.getRelationsDefinitions()); + + if (domain.relationsDefinitions() == null) { + jpa.setRelationsDefinitions(new LinkedHashSet<>()); + return; } - // ── Merge helpers to update a managed JPA entity from domain values ── - - private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { - jpa.setIdentifier(domain.identifier()); - jpa.setName(domain.name()); - jpa.setDescription(domain.description()); - mergePropertyDefinitions(jpa, domain); - mergeRelationDefinitions(jpa, domain); - } - - private void mergePropertyDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { - // Work on a mutable copy — getter returns an unmodifiable view - Set existing = new LinkedHashSet<>(jpa.getPropertiesDefinitions()); - - if (domain.propertiesDefinitions() == null) { - jpa.setPropertiesDefinitions(new LinkedHashSet<>()); - return; - } - - Map existingByName = existing.stream() - .collect(Collectors.toMap(PropertyDefinitionJpaEntity::getName, Function.identity())); - - Set updatedNames = domain.propertiesDefinitions().stream() - .map(p -> p.name()) - .collect(Collectors.toSet()); - - // Remove properties no longer present - existing.removeIf(p -> !updatedNames.contains(p.getName())); - - // Update existing or add new - for (var domProp : domain.propertiesDefinitions()) { - PropertyDefinitionJpaEntity ex = existingByName.get(domProp.name()); - if (ex != null) { - ex.setDescription(domProp.description()); - ex.setType(domProp.type()); - ex.setRequired(domProp.required()); - mergeRules(ex, domProp.rules()); - } else { - PropertyDefinitionJpaEntity newProp = PropertyDefinitionJpaEntity.builder() - .id(domProp.id()) - .name(domProp.name()) - .description(domProp.description()) - .type(domProp.type()) - .required(domProp.required()) - .rules(domProp.rules() != null ? toRulesJpa(domProp.rules()) : null) - .build(); - existing.add(newProp); - } - } - - // Push the mutated copy back through the defensive setter - jpa.setPropertiesDefinitions(existing); - } - - private void mergeRules(PropertyDefinitionJpaEntity jpaProp, - com.decathlon.idp_core.domain.model.entity_template.PropertyRules domRules) { - if (domRules == null) { - // No rules in the updated domain – leave existing rules unchanged - return; - } - PropertyRulesJpaEntity ex = jpaProp.getRules(); - if (ex != null) { - // Update the managed entity in-place — Hibernate tracks the dirty fields - ex.setFormat(domRules.format()); - ex.setEnumValues(domRules.enumValues() != null ? domRules.enumValues().toArray(new String[0]) : null); - ex.setRegex(domRules.regex()); - ex.setMaxLength(domRules.maxLength()); - ex.setMinLength(domRules.minLength()); - ex.setMaxValue(domRules.maxValue()); - ex.setMinValue(domRules.minValue()); - // Re-set the reference so Hibernate detects the association as dirty - jpaProp.setRules(ex); - } else { - jpaProp.setRules(toRulesJpa(domRules)); - } - } - - private PropertyRulesJpaEntity toRulesJpa(com.decathlon.idp_core.domain.model.entity_template.PropertyRules d) { - return PropertyRulesJpaEntity.builder() - .id(d.id()).format(d.format()) - .enumValues(d.enumValues() != null ? d.enumValues().toArray(new String[0]) : null) - .regex(d.regex()).maxLength(d.maxLength()).minLength(d.minLength()) - .maxValue(d.maxValue()).minValue(d.minValue()).build(); + Map existingByName = existing.stream() + .collect(Collectors.toMap(RelationDefinitionJpaEntity::getName, Function.identity())); + + Set updatedNames = domain.relationsDefinitions().stream().map(r -> r.name()) + .collect(Collectors.toSet()); + + // Remove relations no longer present + existing.removeIf(r -> !updatedNames.contains(r.getName())); + + // Update existing or add new + for (var domRel : domain.relationsDefinitions()) { + RelationDefinitionJpaEntity ex = existingByName.get(domRel.name()); + if (ex != null) { + ex.setTargetTemplateIdentifier(domRel.targetTemplateIdentifier()); + ex.setRequired(domRel.required()); + ex.setToMany(domRel.toMany()); + } else { + RelationDefinitionJpaEntity newRel = RelationDefinitionJpaEntity.builder() + .name(domRel.name()).targetTemplateIdentifier(domRel.targetTemplateIdentifier()) + .required(domRel.required()).toMany(domRel.toMany()).build(); + existing.add(newRel); + } } - private void mergeRelationDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { - // Work on a mutable copy — getter returns an unmodifiable view - Set existing = new LinkedHashSet<>(jpa.getRelationsDefinitions()); - - if (domain.relationsDefinitions() == null) { - jpa.setRelationsDefinitions(new LinkedHashSet<>()); - return; - } - - Map existingByName = existing.stream() - .collect(Collectors.toMap(RelationDefinitionJpaEntity::getName, Function.identity())); - - Set updatedNames = domain.relationsDefinitions().stream() - .map(r -> r.name()) - .collect(Collectors.toSet()); - - // Remove relations no longer present - existing.removeIf(r -> !updatedNames.contains(r.getName())); - - // Update existing or add new - for (var domRel : domain.relationsDefinitions()) { - RelationDefinitionJpaEntity ex = existingByName.get(domRel.name()); - if (ex != null) { - ex.setTargetTemplateIdentifier(domRel.targetTemplateIdentifier()); - ex.setRequired(domRel.required()); - ex.setToMany(domRel.toMany()); - } else { - RelationDefinitionJpaEntity newRel = RelationDefinitionJpaEntity.builder() - .name(domRel.name()) - .targetTemplateIdentifier(domRel.targetTemplateIdentifier()) - .required(domRel.required()) - .toMany(domRel.toMany()) - .build(); - existing.add(newRel); - } - } - - // Push the mutated copy back through the defensive setter - jpa.setRelationsDefinitions(existing); - } + // Push the mutated copy back through the defensive setter + jpa.setRelationsDefinitions(existing); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java index 42728f1..53e5571 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java @@ -14,11 +14,12 @@ @RequiredArgsConstructor public class PostgresRelationAdapter implements RelationRepositoryPort { - private final JpaRelationRepository jpaRelationRepository; + private final JpaRelationRepository jpaRelationRepository; - @Override - public List findRelationsSummariesByTargetEntityIdentifiers( - List targetEntityIdentifiers) { - return jpaRelationRepository.findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); - } + @Override + public List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers) { + return jpaRelationRepository + .findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index cc7edac..ba0e8eb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -15,15 +15,15 @@ @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public interface EntityPersistenceMapper { - Entity toDomain(EntityJpaEntity jpa); + Entity toDomain(EntityJpaEntity jpa); - EntityJpaEntity toJpa(Entity domain); + EntityJpaEntity toJpa(Entity domain); - Property toDomain(PropertyJpaEntity jpa); + Property toDomain(PropertyJpaEntity jpa); - PropertyJpaEntity toJpa(Property domain); + PropertyJpaEntity toJpa(Property domain); - Relation toDomain(RelationJpaEntity jpa); + Relation toDomain(RelationJpaEntity jpa); - RelationJpaEntity toJpa(Relation domain); + RelationJpaEntity toJpa(Relation domain); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java index 33b1990..a7c032e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java @@ -13,25 +13,22 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.PropertyDefinitionJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.RelationDefinitionJpaEntity; -@Mapper( - componentModel = MappingConstants.ComponentModel.SPRING, - collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED -) +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED) public interface EntityTemplatePersistenceMapper { - EntityTemplate toDomain(EntityTemplateJpaEntity jpa); + EntityTemplate toDomain(EntityTemplateJpaEntity jpa); - EntityTemplateJpaEntity toJpa(EntityTemplate domain); + EntityTemplateJpaEntity toJpa(EntityTemplate domain); - PropertyDefinition toDomain(PropertyDefinitionJpaEntity jpa); + PropertyDefinition toDomain(PropertyDefinitionJpaEntity jpa); - PropertyDefinitionJpaEntity toJpa(PropertyDefinition domain); + PropertyDefinitionJpaEntity toJpa(PropertyDefinition domain); - PropertyRules toDomain(PropertyRulesJpaEntity jpa); + PropertyRules toDomain(PropertyRulesJpaEntity jpa); - PropertyRulesJpaEntity toJpa(PropertyRules domain); + PropertyRulesJpaEntity toJpa(PropertyRules domain); - RelationDefinition toDomain(RelationDefinitionJpaEntity jpa); + RelationDefinition toDomain(RelationDefinitionJpaEntity jpa); - RelationDefinitionJpaEntity toJpa(RelationDefinition domain); + RelationDefinitionJpaEntity toJpa(RelationDefinition domain); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 848693d..72aea57 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -14,6 +14,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,37 +23,30 @@ @jakarta.persistence.Entity @Data @Table(name = "entity", uniqueConstraints = { - @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) -}) + @UniqueConstraint(columnNames = {"identifier", "template_identifier"})}) @Builder @NoArgsConstructor @AllArgsConstructor public class EntityJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(name = "template_identifier") - private String templateIdentifier; + @Column(name = "template_identifier") + private String templateIdentifier; - private String name; + private String name; - private String identifier; + private String identifier; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_properties", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "property_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "property_id"}), - indexes = @Index(columnList = "entity_id")) - private List properties; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_properties", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "property_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "property_id"}), indexes = @Index(columnList = "entity_id")) + private List properties; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_relations", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "relation_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "relation_id"}), - indexes = @Index(columnList = "entity_id")) - private List relations; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_relations", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "relation_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "relation_id"}), indexes = @Index(columnList = "entity_id")) + private List relations; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java index a66be8d..961ac6d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java @@ -8,6 +8,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -21,13 +22,13 @@ @AllArgsConstructor public class PropertyJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(nullable = false) - private String name; + @Column(nullable = false) + private String name; - @Column - private String value; + @Column + private String value; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java index aae2d25..4e0663a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java @@ -2,8 +2,6 @@ import java.util.UUID; -import com.decathlon.idp_core.domain.model.enums.PropertyFormat; - import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -11,6 +9,9 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; + +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,17 +25,17 @@ @AllArgsConstructor public class PropertyRulesJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Enumerated(EnumType.STRING) - private PropertyFormat format; + @Enumerated(EnumType.STRING) + private PropertyFormat format; - private String[] enumValues; - private String regex; - private Integer maxLength; - private Integer minLength; - private Integer maxValue; - private Integer minValue; + private String[] enumValues; + private String regex; + private Integer maxLength; + private Integer minLength; + private Integer maxValue; + private Integer minValue; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java index a4e9176..d135f1f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java @@ -13,6 +13,7 @@ import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,20 +27,18 @@ @AllArgsConstructor public class RelationJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(nullable = false) - private String name; + @Column(nullable = false) + private String name; - @Column(name = "target_template_identifier", nullable = false) - private String targetTemplateIdentifier; + @Column(name = "target_template_identifier", nullable = false) + private String targetTemplateIdentifier; - @ElementCollection - @CollectionTable(name = "relation_target_entities", - joinColumns = @JoinColumn(name = "relation_id"), - indexes = @Index(columnList = "relation_id")) - @Column(name = "target_entity_identifier") - private List targetEntityIdentifiers; + @ElementCollection + @CollectionTable(name = "relation_target_entities", joinColumns = @JoinColumn(name = "relation_id"), indexes = @Index(columnList = "relation_id")) + @Column(name = "target_entity_identifier") + private List targetEntityIdentifiers; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java index ecff6d3..9588fc2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java @@ -16,6 +16,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -33,59 +34,59 @@ @AllArgsConstructor public class EntityTemplateJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(nullable = false, unique = true) - private String identifier; + @Column(nullable = false, unique = true) + private String identifier; - @Column(nullable = false, unique = true) - private String name; + @Column(nullable = false, unique = true) + private String name; - private String description; + private String description; - @Getter(lombok.AccessLevel.NONE) - @Setter(lombok.AccessLevel.NONE) - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_template_properties_definitions", - joinColumns = @JoinColumn(name = "entity_template_id"), - inverseJoinColumns = @JoinColumn(name = "properties_definitions_id")) - @OrderBy("name ASC") - private Set propertiesDefinitions = new LinkedHashSet<>(); + @Getter(lombok.AccessLevel.NONE) + @Setter(lombok.AccessLevel.NONE) + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_template_properties_definitions", joinColumns = @JoinColumn(name = "entity_template_id"), inverseJoinColumns = @JoinColumn(name = "properties_definitions_id")) + @OrderBy("name ASC") + private Set propertiesDefinitions = new LinkedHashSet<>(); - @Getter(lombok.AccessLevel.NONE) - @Setter(lombok.AccessLevel.NONE) - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_template_relations_definitions", - joinColumns = @JoinColumn(name = "entity_template_id"), - inverseJoinColumns = @JoinColumn(name = "relations_definitions_id")) - @OrderBy("name ASC") - private Set relationsDefinitions = new LinkedHashSet<>(); + @Getter(lombok.AccessLevel.NONE) + @Setter(lombok.AccessLevel.NONE) + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_template_relations_definitions", joinColumns = @JoinColumn(name = "entity_template_id"), inverseJoinColumns = @JoinColumn(name = "relations_definitions_id")) + @OrderBy("name ASC") + private Set relationsDefinitions = new LinkedHashSet<>(); - /// Returns an unmodifiable view of the internal collection to prevent external mutation. - public Set getPropertiesDefinitions() { - return Collections.unmodifiableSet(propertiesDefinitions); - } + /// Returns an unmodifiable view of the internal collection to prevent external + /// mutation. + public Set getPropertiesDefinitions() { + return Collections.unmodifiableSet(propertiesDefinitions); + } - /// Defensive copy setter to prevent external mutation of the internal collection. - public void setPropertiesDefinitions(Set propertiesDefinitions) { - this.propertiesDefinitions.clear(); - if (propertiesDefinitions != null) { - this.propertiesDefinitions.addAll(propertiesDefinitions); - } + /// Defensive copy setter to prevent external mutation of the internal + /// collection. + public void setPropertiesDefinitions(Set propertiesDefinitions) { + this.propertiesDefinitions.clear(); + if (propertiesDefinitions != null) { + this.propertiesDefinitions.addAll(propertiesDefinitions); } + } - /// Returns an unmodifiable view of the internal collection to prevent external mutation. - public Set getRelationsDefinitions() { - return Collections.unmodifiableSet(relationsDefinitions); - } + /// Returns an unmodifiable view of the internal collection to prevent external + /// mutation. + public Set getRelationsDefinitions() { + return Collections.unmodifiableSet(relationsDefinitions); + } - /// Defensive copy setter to prevent external mutation of the internal collection. - public void setRelationsDefinitions(Set relationsDefinitions) { - this.relationsDefinitions.clear(); - if (relationsDefinitions != null) { - this.relationsDefinitions.addAll(relationsDefinitions); - } + /// Defensive copy setter to prevent external mutation of the internal + /// collection. + public void setRelationsDefinitions(Set relationsDefinitions) { + this.relationsDefinitions.clear(); + if (relationsDefinitions != null) { + this.relationsDefinitions.addAll(relationsDefinitions); } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java index 5e65e34..c11cfbb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java @@ -1,9 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template; import java.util.UUID; -import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyRulesJpaEntity; - import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -13,6 +10,10 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; + +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyRulesJpaEntity; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,20 +29,20 @@ @AllArgsConstructor public class PropertyDefinitionJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @EqualsAndHashCode.Include - private String name; - private String description; + @EqualsAndHashCode.Include + private String name; + private String description; - @Enumerated(EnumType.STRING) - private PropertyType type; + @Enumerated(EnumType.STRING) + private PropertyType type; - @Builder.Default - private boolean required = false; + @Builder.Default + private boolean required = false; - @OneToOne(cascade = CascadeType.ALL) - private PropertyRulesJpaEntity rules; + @OneToOne(cascade = CascadeType.ALL) + private PropertyRulesJpaEntity rules; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java index 219c3b9..6310fb2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,18 +23,18 @@ @AllArgsConstructor public class RelationDefinitionJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @EqualsAndHashCode.Include - private String name; + @EqualsAndHashCode.Include + private String name; - private String targetTemplateIdentifier; + private String targetTemplateIdentifier; - @Builder.Default - private boolean required = false; + @Builder.Default + private boolean required = false; - @Builder.Default - private boolean toMany = false; + @Builder.Default + private boolean toMany = false; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 8be0fa7..d9cbcb9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -19,153 +19,158 @@ @Repository public interface JpaEntityRepository extends JpaRepository { - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") - List findByIdentifierIn(List identifiers); - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") - List findByRelationIdIn(List relationIds); - - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - - Optional findByIdentifier(String identifier); - - Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); - - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 - WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames - ) - """) - void deletePropertiesByTemplateIdentifierAndPropertyName( - @Param("templateIdentifier") String templateIdentifier, - @Param("propertyNames") Collection propertyNames); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM RelationJpaEntity r - WHERE r IN ( - SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 - WHERE e.templateIdentifier = :templateIdentifier - AND r2.name IN :relationNames - ) - """) - void deleteRelationsByTemplateIdentifierAndRelationName( - @Param("templateIdentifier") String templateIdentifier, - @Param("relationNames") Collection relationNames); - - /// Batch fetch entities by identifiers with eager loading of relations and properties. - /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. - /// First fetches entities with relations, then fetches properties separately. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") - List findAllByIdentifierInWithRelations(@Param("identifiers") Collection identifiers); - - /// Fetch properties for entities that were already loaded. - /// This is called after findAllByIdentifierInWithRelations to complete the entity graph. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") - List findAllByIdentifierInWithProperties(@Param("identifiers") Collection identifiers); - - @Query(value = """ - WITH RECURSIVE - -- Traverse outbound relations (this entity -> targets) - outbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, og.depth + 1 - FROM outbound_graph og - JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier - JOIN entity_relations er ON er.entity_id = e.id - JOIN relation r ON r.id = er.relation_id - JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier - WHERE og.depth < :depth - ), - -- Traverse inbound relations (sources -> this entity as target) - inbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - FROM inbound_graph ig - JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier - JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id - JOIN entity_relations er ON er.relation_id = r.id - JOIN entity e2 ON e2.id = er.entity_id - WHERE ig.depth < :depth - ) - SELECT DISTINCT identifier, template_identifier FROM outbound_graph - UNION - SELECT DISTINCT identifier, template_identifier FROM inbound_graph - """, nativeQuery = true) - List findEntityGraphIdentifiers( - @Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier, - @Param("depth") int depth); - - /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the given relation names. - /// When the list is empty, all relation names are followed (no filter). - /// The filter is applied inside both the outbound and inbound recursive CTE steps so that only - /// entities reachable through the specified relations are returned, keeping the result set lean. - @Query(value = """ - WITH RECURSIVE - outbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, og.depth + 1 - FROM outbound_graph og - JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier - JOIN entity_relations er ON er.entity_id = e.id - JOIN relation r ON r.id = er.relation_id - JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier - WHERE og.depth < :depth - AND r.name IN :relationNames - ), - inbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - FROM inbound_graph ig - JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier - JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id - JOIN entity_relations er ON er.relation_id = r.id - JOIN entity e2 ON e2.id = er.entity_id - WHERE ig.depth < :depth - AND r.name IN :relationNames - ) - SELECT DISTINCT identifier, template_identifier FROM outbound_graph - UNION - SELECT DISTINCT identifier, template_identifier FROM inbound_graph - """, nativeQuery = true) - List findEntityGraphIdentifiersFilteredByRelations( - @Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier, - @Param("depth") int depth, - @Param("relationNames") Collection relationNames); + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") + List findByIdentifierIn(List identifiers); + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") + List findByRelationIdIn(List relationIds); + + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); + + Optional findByIdentifier(String identifier); + + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM PropertyJpaEntity p + WHERE p IN ( + SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + WHERE e.templateIdentifier = :templateIdentifier + AND p2.name IN :propertyNames + ) + """) + void deletePropertiesByTemplateIdentifierAndPropertyName( + @Param("templateIdentifier") String templateIdentifier, + @Param("propertyNames") Collection propertyNames); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 + WHERE e.templateIdentifier = :templateIdentifier + AND r2.name IN :relationNames + ) + """) + void deleteRelationsByTemplateIdentifierAndRelationName( + @Param("templateIdentifier") String templateIdentifier, + @Param("relationNames") Collection relationNames); + + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. + /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. + /// First fetches entities with relations, then fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations( + @Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. + /// This is called after findAllByIdentifierInWithRelations to complete the + /// entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties( + @Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers(@Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the + /// given relation names. + /// When the list is empty, all relation names are followed (no filter). + /// The filter is applied inside both the outbound and inbound recursive CTE + /// steps so that only + /// entities reachable through the specified relations are returned, keeping the + /// result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth, + @Param("relationNames") Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java index 3fe1e70..21b4218 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java @@ -15,22 +15,24 @@ @Repository public interface JpaEntityTemplateRepository extends JpaRepository { - @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) - Optional findByIdentifier(String templateIdentifier); + @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", + "relationsDefinitions"}) + Optional findByIdentifier(String templateIdentifier); - @Override - @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) - Optional findById(UUID id); + @Override + @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", + "relationsDefinitions"}) + Optional findById(UUID id); - @Override - @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) - Page findAll(Pageable pageable); + @Override + @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", + "relationsDefinitions"}) + Page findAll(Pageable pageable); + boolean existsByIdentifier(String identifier); - boolean existsByIdentifier(String identifier); + boolean existsByName(String name); - boolean existsByName(String name); - - @Transactional - void deleteByIdentifier(String identifier); + @Transactional + void deleteByIdentifier(String identifier); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 57c0c66..4e642d6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -14,15 +14,15 @@ @Repository public interface JpaRelationRepository extends JpaRepository { - @Query(""" - SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( - tei, r.name, e.identifier, e.name - ) - FROM EntityJpaEntity e - JOIN e.relations r - JOIN r.targetEntityIdentifiers tei - WHERE tei IN :targetEntityIdentifiers - """) - List findRelationsSummariesByTargetEntityIdentifiers( - @Param("targetEntityIdentifiers") List targetEntityIdentifiers); + @Query(""" + SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( + tei, r.name, e.identifier, e.name + ) + FROM EntityJpaEntity e + JOIN e.relations r + JOIN r.targetEntityIdentifiers tei + WHERE tei IN :targetEntityIdentifiers + """) + List findRelationsSummariesByTargetEntityIdentifiers( + @Param("targetEntityIdentifiers") List targetEntityIdentifiers); } diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index a0a35cf..ff890dd 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -66,188 +66,187 @@ @TestClassOrder(ClassOrderer.ClassName.class) public abstract class AbstractIntegrationTest { - @Autowired - public MockMvc mockMvc; + @Autowired + public MockMvc mockMvc; - public final ObjectMapper objectMapper; + public final ObjectMapper objectMapper; - public final ObjectMapper userEventObjectMapper; + public final ObjectMapper userEventObjectMapper; - public static ClientAndServer clientAndServer; + public static ClientAndServer clientAndServer; - public static MockServerClient mockServerClient; + public static MockServerClient mockServerClient; - public static AtomicBoolean initToDo = new AtomicBoolean(true); + public static AtomicBoolean initToDo = new AtomicBoolean(true); - protected AbstractIntegrationTest() { - this.objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - this.userEventObjectMapper = new ObjectMapper(); - userEventObjectMapper.registerModule(new JavaTimeModule()); - userEventObjectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - userEventObjectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); - } - - @Container - @SuppressWarnings("rawtypes") - private static final JdbcDatabaseContainer postgres = new PostgreSQLContainer("postgres:18-alpine") - .withDatabaseName("idp-core").withUsername("idp-core").withPassword("idp-core"); - - @DynamicPropertySource - static void postgresProperties(DynamicPropertyRegistry registry) { - postgres.start(); - LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); // wait for container to be ready - - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - - } - - public void startMockServer() { - if (mockServerClient == null) { - clientAndServer = startClientAndServer(8888); - mockServerClient = new MockServerClient("localhost", 8888); - } - } - - @SafeVarargs - public final void mockApiCall(String path, HttpStatus status, Pair... queryParameterList) { - mockApiCall(GET, path, status, null, queryParameterList); - } - - @SafeVarargs - public final void mockApiCall(String path, Object response, Pair... queryParameterList) { - mockApiCall(GET, path, OK, response, queryParameterList); - } + protected AbstractIntegrationTest() { + this.objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + this.userEventObjectMapper = new ObjectMapper(); + userEventObjectMapper.registerModule(new JavaTimeModule()); + userEventObjectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + userEventObjectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + } - @SafeVarargs - public final void mockApiCall(HttpMethod httpMethod, String path, Object response, - Pair... queryParameterList) { - mockApiCall(httpMethod, path, OK, response, queryParameterList); - } - - @SafeVarargs - public final void mockApiCall(HttpMethod httpMethod, String path, HttpStatus status, Object response, - Pair... queryParameterList) { - startMockServer(); - HttpRequest requestDefinition = getRequestDefinition(httpMethod, path, queryParameterList); - mockServerClient.clear(requestDefinition); - mockServerClient.when(requestDefinition) - .respond(HttpResponse.response() - .withStatusCode(status.value()) - .withHeaders(new Header(CONTENT_TYPE, APPLICATION_JSON_VALUE)) - .withBody(response != null ? writeValueAsString(response) : null)); - } + @Container + @SuppressWarnings("rawtypes") + private static final JdbcDatabaseContainer postgres = new PostgreSQLContainer( + "postgres:18-alpine").withDatabaseName("idp-core").withUsername("idp-core") + .withPassword("idp-core"); - @SafeVarargs - private static HttpRequest getRequestDefinition(HttpMethod httpMethod, String path, - Pair... queryParameterList) { - HttpRequest requestDefinition = request().withMethod(httpMethod.name()).withPath(path); + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + postgres.start(); + LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); // wait for container to be ready - if (queryParameterList != null) { - for (Pair queryParameter : queryParameterList) { - requestDefinition.withQueryStringParameter(queryParameter.getKey(), queryParameter.getValue()); - } - } + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); - return requestDefinition; - } + } - @SneakyThrows - public static String getJsonTestFileContent(String path) { - try (var inputStream = new ClassPathResource(path).getInputStream()) { - return IOUtils.toString(inputStream, UTF_8); - } + public void startMockServer() { + if (mockServerClient == null) { + clientAndServer = startClientAndServer(8888); + mockServerClient = new MockServerClient("localhost", 8888); } - - @SneakyThrows - public String writeValueAsString(Object object) { - if (object instanceof String) { - return (String) object; - } - return objectMapper.writeValueAsString(object); + } + + @SafeVarargs + public final void mockApiCall(String path, HttpStatus status, + Pair... queryParameterList) { + mockApiCall(GET, path, status, null, queryParameterList); + } + + @SafeVarargs + public final void mockApiCall(String path, Object response, + Pair... queryParameterList) { + mockApiCall(GET, path, OK, response, queryParameterList); + } + + @SafeVarargs + public final void mockApiCall(HttpMethod httpMethod, String path, Object response, + Pair... queryParameterList) { + mockApiCall(httpMethod, path, OK, response, queryParameterList); + } + + @SafeVarargs + public final void mockApiCall(HttpMethod httpMethod, String path, HttpStatus status, + Object response, Pair... queryParameterList) { + startMockServer(); + HttpRequest requestDefinition = getRequestDefinition(httpMethod, path, queryParameterList); + mockServerClient.clear(requestDefinition); + mockServerClient.when(requestDefinition) + .respond(HttpResponse.response().withStatusCode(status.value()) + .withHeaders(new Header(CONTENT_TYPE, APPLICATION_JSON_VALUE)) + .withBody(response != null ? writeValueAsString(response) : null)); + } + + @SafeVarargs + private static HttpRequest getRequestDefinition(HttpMethod httpMethod, String path, + Pair... queryParameterList) { + HttpRequest requestDefinition = request().withMethod(httpMethod.name()).withPath(path); + + if (queryParameterList != null) { + for (Pair queryParameter : queryParameterList) { + requestDefinition.withQueryStringParameter(queryParameter.getKey(), + queryParameter.getValue()); + } } - /// Helper method to perform a POST request and validate that it returns a - /// BAD_REQUEST response. - /// - /// @param path the URL path to send the POST request to - /// @param jsonBodyfilePath the file path containing the JSON content to be sent - /// in the request body - /// @param errorDescription the expected error description that should be - /// returned in the response - /// @throws Exception if an error occurs during the mock MVC request execution - public MvcResult postBadRequestAndAssertEquals(String path, String jsonBodyfilePath, String errorDescription) - throws Exception { - return mockMvc.perform(MockMvcRequestBuilders.post(path) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(jsonBodyfilePath))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(errorDescription)) - .andReturn(); + return requestDefinition; + } + @SneakyThrows + public static String getJsonTestFileContent(String path) { + try (var inputStream = new ClassPathResource(path).getInputStream()) { + return IOUtils.toString(inputStream, UTF_8); } + } - /// Helper method to perform a POST request and validate that it returns a - /// BAD_REQUEST response and that the error description contains the expected text. - /// - /// @param path the URL path to send the POST request to - /// @param jsonBodyfilePath the file path containing the JSON content to be sent - /// in the request body - /// @param errorDescription the text that should be contained in the error description - /// @throws Exception if an error occurs during the mock MVC request execution - public MvcResult postBadRequestAndAssertContains(String path, String jsonBodyfilePath, String errorDescription) - throws Exception { - return mockMvc.perform(MockMvcRequestBuilders.post(path) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(jsonBodyfilePath))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) - .andReturn(); - + @SneakyThrows + public String writeValueAsString(Object object) { + if (object instanceof String) { + return (String) object; } - - /// Helper method to perform a POST request and validate that it returns a - /// CONFLICT response and that the error description contains the expected text. - /// - /// @param path the URL path to send the POST request to - /// @param jsonBodyfilePath the file path containing the JSON content to be sent - /// in the request body - /// @param errorDescription the text that should be contained in the error description - /// @throws Exception if an error occurs during the mock MVC request execution - public MvcResult postConflictAndAssertContains(String path, String jsonBodyfilePath, String errorDescription) - throws Exception { - return mockMvc.perform(MockMvcRequestBuilders.post(path) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(jsonBodyfilePath))) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error").value("CONFLICT")) - .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) - .andReturn(); - + return objectMapper.writeValueAsString(object); + } + + /// Helper method to perform a POST request and validate that it returns a + /// BAD_REQUEST response. + /// + /// @param path the URL path to send the POST request to + /// @param jsonBodyfilePath the file path containing the JSON content to be sent + /// in the request body + /// @param errorDescription the expected error description that should be + /// returned in the response + /// @throws Exception if an error occurs during the mock MVC request execution + public MvcResult postBadRequestAndAssertEquals(String path, String jsonBodyfilePath, + String errorDescription) throws Exception { + return mockMvc + .perform( + MockMvcRequestBuilders.post(path).contentType(APPLICATION_JSON).accept(APPLICATION_JSON) + .with(csrf()).content(getJsonTestFileContent(jsonBodyfilePath))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(errorDescription)).andReturn(); + + } + + /// Helper method to perform a POST request and validate that it returns a + /// BAD_REQUEST response and that the error description contains the expected + /// text. + /// + /// @param path the URL path to send the POST request to + /// @param jsonBodyfilePath the file path containing the JSON content to be sent + /// in the request body + /// @param errorDescription the text that should be contained in the error + /// description + /// @throws Exception if an error occurs during the mock MVC request execution + public MvcResult postBadRequestAndAssertContains(String path, String jsonBodyfilePath, + String errorDescription) throws Exception { + return mockMvc + .perform( + MockMvcRequestBuilders.post(path).contentType(APPLICATION_JSON).accept(APPLICATION_JSON) + .with(csrf()).content(getJsonTestFileContent(jsonBodyfilePath))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) + .andReturn(); + + } + + /// Helper method to perform a POST request and validate that it returns a + /// CONFLICT response and that the error description contains the expected text. + /// + /// @param path the URL path to send the POST request to + /// @param jsonBodyfilePath the file path containing the JSON content to be sent + /// in the request body + /// @param errorDescription the text that should be contained in the error + /// description + /// @throws Exception if an error occurs during the mock MVC request execution + public MvcResult postConflictAndAssertContains(String path, String jsonBodyfilePath, + String errorDescription) throws Exception { + return mockMvc + .perform( + MockMvcRequestBuilders.post(path).contentType(APPLICATION_JSON).accept(APPLICATION_JSON) + .with(csrf()).content(getJsonTestFileContent(jsonBodyfilePath))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) + .andReturn(); + + } + + /// Helper method to perform a PUT request and validate that it returns a + @TestConfiguration + public static class TestBeanConfiguration { + + WebClient webClient = WebClient.builder().baseUrl("http://localhost:8888").build(); + + @Bean + @Primary + JwtDecoder jwtDecoder() { + return mock(JwtDecoder.class); } - /// Helper method to perform a PUT request and validate that it returns a - @TestConfiguration - public static class TestBeanConfiguration { - - WebClient webClient = WebClient.builder().baseUrl("http://localhost:8888").build(); - - @Bean - @Primary - JwtDecoder jwtDecoder() { - return mock(JwtDecoder.class); - } - - } + } } diff --git a/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java b/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java index bc8fc08..3983193 100644 --- a/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java +++ b/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java @@ -14,7 +14,7 @@ /// This configuration ensures all requests are permitted without authentication. @TestConfiguration @EnableWebSecurity -@Profile({ "test" }) +@Profile({"test"}) public class TestSecurityConfiguration { /// Configures a permissive security filter chain for testing. /// @@ -22,7 +22,8 @@ public class TestSecurityConfiguration { /// - CSRF protection disabled (not needed for API tests) /// - All requests permitted without authentication /// - /// **Why permissive security:** Test scenarios focus on business logic validation + /// **Why permissive security:** Test scenarios focus on business logic + /// validation /// rather than security enforcement, requiring unrestricted access. @Bean public SecurityFilterChain securityFilterChainTest(HttpSecurity http) throws Exception { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 22b4cb9..fa92ecf 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -38,127 +38,128 @@ @DisplayName("EntityService Tests") class EntityServiceTest { - @Mock - private EntityRepositoryPort entityRepository; + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @Mock + private EntityTemplateService entityTemplateService; + + @InjectMocks + private EntityService entityService; - - @Mock - private EntityValidationService entityValidationService; - - @Mock - private EntityTemplateValidationService entityTemplateValidationService; - - @Mock - private EntityTemplateService entityTemplateService; - - @InjectMocks - private EntityService entityService; - - @Test - @DisplayName("Should return entities page by template identifier") - void shouldReturnEntitiesByTemplateIdentifier() { - var pageable = Pageable.ofSize(10); - var entity = entity("template-a", "entity-a", "Entity A"); - var page = new PageImpl<>(List.of(entity)); - - when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); - - var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); - - assertSame(page, result); - verify(entityRepository).findByTemplateIdentifier("template-a", pageable); - } - - @Test - @DisplayName("Should return entity summaries by identifiers") - void shouldReturnEntitySummariesByIdentifiers() { - var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); - when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); - - var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); - - assertEquals(summaries, result); - verify(entityRepository).findByIdentifierIn(List.of("service-a")); - } - - @Test - @DisplayName("Should return entity by template and identifier") - void shouldReturnEntityByTemplateAndIdentifier() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - - assertSame(entity, result); - verify(entityTemplateValidationService).validateTemplateExists("web-service"); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - } - - @Test - @DisplayName("Should throw when entity is not found for template") - void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) - .thenReturn(Optional.empty()); - - assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); - } - - @Test - @DisplayName("Should create entity when validations pass") - void shouldCreateEntityWhenValidationsPass() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.save(entity)).thenReturn(entity); - - var result = entityService.createEntity(entity); - - assertSame(entity, result); - - InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateForCreation(entity, template); - inOrder.verify(entityRepository).save(entity); - verifyNoInteractions(entityTemplateValidationService); - } - - @Test - @DisplayName("Should not save when entity already exists") - void shouldNotSaveWhenEntityAlreadyExists() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); - - assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateForCreation(entity, template); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should stop immediately when template does not exist") - void shouldStopWhenTemplateDoesNotExistOnCreate() { - var entity = entity("missing-template", "catalog-api", "Catalog API"); - - when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) - .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); - verifyNoInteractions(entityValidationService); - verifyNoMoreInteractions(entityRepository); - } - - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); - } + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", + "catalog-api"); + + assertSame(entity, result); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> entityService + .getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateForCreation(entity, template); + inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateForCreation(entity, template); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6411bd6..ffadd76 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -37,195 +37,134 @@ @DisplayName("EntityValidationService Tests") class EntityValidationServiceTest { - @Mock - private EntityRepositoryPort entityRepository; - - - @Mock - private PropertyValidationService propertyValidationService; - - @InjectMocks - private EntityValidationService entityValidationService; - - @Test - @DisplayName("Should throw when entity with same identifier already exists") - void shouldThrowWhenEntityAlreadyExists() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); - } - - @Test - @DisplayName("Should not query repository when identifier is null") - void shouldNotQueryRepositoryWhenIdentifierIsNull() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); - - var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - - verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); - } - - - @Test - @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateForCreation() { - var portDefinition = new PropertyDefinition( - UUID.randomUUID(), - "port", - "Port", - PropertyType.NUMBER, - true, - new PropertyRules(null, null, null, null, null, null, 65535, 1024)); - - var requiredDefinition = new PropertyDefinition( - UUID.randomUUID(), - "ownerEmail", - "Owner email", - PropertyType.STRING, - true, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(requiredDefinition, portDefinition), - List.of()); - - var entity = entity( - "web-service", - " ", // Blank identifier (handled by Jakarta, not this service) - " ", // Blank name (handled by Jakarta, not this service) - List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of()); // No relations - - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) - .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); - - // Expecting exactly 2 errors: the missing required property, and the invalid port value. - assertEquals(2, exception.getViolations().size()); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); - } - - @Test - @DisplayName("Should validate entity successfully when no violations") - void shouldValidateForCreationSuccessfullyWhenNoViolations() { - var versionDefinition = new PropertyDefinition( - UUID.randomUUID(), - "version", - "Version", - PropertyType.STRING, - false, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(versionDefinition), - List.of()); - - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), - null); - - - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); - } - - @Test - @DisplayName("Should skip property rule validation for missing optional property") - void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { - var optionalDefinition = new PropertyDefinition( - UUID.randomUUID(), - "version", - "Version", - PropertyType.STRING, - false, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(optionalDefinition), - List.of()); - - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verifyNoInteractions(propertyValidationService); - } - - @Test - @DisplayName("Should validate property of type STRING with a numeric string value '1234'") - void shouldValidateStringPropertyWithNumericStringValue() { - var stringDefinition = new PropertyDefinition( - UUID.randomUUID(), - "versionCode", - "Version Code as String", - PropertyType.STRING, - false, - null - ); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(stringDefinition), - List.of()); - - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), - null); - when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); - } - - private Entity entity( - String templateIdentifier, - String identifier, - String name, - List properties, - List relations) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); - } + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, + () -> entityValidationService.validateForCreation(entity, template)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Should aggregate property requirements and rule violations") + void shouldAggregateAllViolationsDuringValidateForCreation() { + var portDefinition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", + PropertyType.NUMBER, true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition(UUID.randomUUID(), "ownerEmail", "Owner email", + PropertyType.STRING, true, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(requiredDefinition, portDefinition), List.of()); + + var entity = entity("web-service", " ", // Blank identifier (handled by Jakarta, not this + // service) + " ", // Blank name (handled by Jakarta, not this service) + List.of(new Property(UUID.randomUUID(), " ", " "), + new Property(UUID.randomUUID(), "port", "80")), + List.of()); // No relations + + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + // Expecting exactly 2 errors: the missing required property, and the invalid + // port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), + exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", + exception.getViolations().get(1)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateForCreationSuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition(UUID.randomUUID(), "version", "Version", + PropertyType.STRING, false, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(versionDefinition), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), null); + + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")) + .thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition(UUID.randomUUID(), "version", "Version", + PropertyType.STRING, false, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(optionalDefinition), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verifyNoInteractions(propertyValidationService); + } + + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition(UUID.randomUUID(), "versionCode", + "Version Code as String", PropertyType.STRING, false, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(stringDefinition), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")) + .thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + + private Entity entity(String templateIdentifier, String identifier, String name, + List properties, List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + relations); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0f6b50f..768efc8 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -35,344 +35,328 @@ @DisplayName("EntityGraphService Tests") class EntityGraphServiceTest { - @Mock - private EntityRepositoryPort entityRepositoryPort; + @Mock + private EntityRepositoryPort entityRepositoryPort; - @Mock - private EntityGraphRepositoryPort entityGraphRepositoryPort; + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; - @InjectMocks - private EntityGraphService entityGraphService; + @InjectMocks + private EntityGraphService entityGraphService; - // --- Fixtures --- + // --- Fixtures --- - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); - } + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } - private Entity entityWithRelations(String templateIdentifier, String identifier, String name, - List relations) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); - } + private Entity entityWithRelations(String templateIdentifier, String identifier, String name, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + relations); + } - private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { - return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); - } + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); + } - private EntityCompositeKey key(String templateIdentifier, String identifier) { - return new EntityCompositeKey(templateIdentifier, identifier); - } + private EntityCompositeKey key(String templateIdentifier, String identifier) { + return new EntityCompositeKey(templateIdentifier, identifier); + } - private static final String TEMPLATE = "web-service"; + private static final String TEMPLATE = "web-service"; - // --- Helper to stub both ports --- + // --- Helper to stub both ports --- - private void stubGraph(Map entityMap) { - when(entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) - .thenReturn(entityMap); - } + private void stubGraph(Map entityMap) { + when( + entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } - // ======================== - @Nested - @DisplayName("Root Entity Not Found") - class RootEntityNotFound { + // ======================== + @Nested + @DisplayName("Root Entity Not Found") + class RootEntityNotFound { - @Test - @DisplayName("Should throw EntityNotFoundException when root entity does not exist") - void shouldThrowWhenRootEntityNotFound() { - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) - .thenReturn(Optional.empty()); + @Test + @DisplayName("Should throw EntityNotFoundException when root entity does not exist") + void shouldThrowWhenRootEntityNotFound() { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) + .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) - .isInstanceOf(EntityNotFoundException.class); + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) + .isInstanceOf(EntityNotFoundException.class); - verify(entityGraphRepositoryPort, never()) - .findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); - } + verify(entityGraphRepositoryPort, never()).findEntityGraph(anyString(), anyString(), anyInt(), + anyBoolean()); } - - // ======================== - @Nested - @DisplayName("Single Root — No Relations") - class SingleRootNoRelations { - - @Test - @DisplayName("Should return leaf node when entity has no relations") - void shouldReturnLeafNodeWhenNoRelations() { - Entity api = entity(TEMPLATE, "api", "API Service"); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.identifier()).isEqualTo("api"); - assertThat(result.name()).isEqualTo("API Service"); - assertThat(result.relations()).isEmpty(); - assertThat(result.relationsAsTarget()).isEmpty(); - } + } + + // ======================== + @Nested + @DisplayName("Single Root — No Relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); } - - // ======================== - @Nested - @DisplayName("Outbound Relations") - class OutboundRelations { - - @Test - @DisplayName("Should resolve outbound relation targets at depth 1") - void shouldResolveOutboundRelations() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses-db", "database", "postgres"))); - Entity postgres = entity("database", "postgres", "Postgres DB"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key("database", "postgres"), postgres)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relations()).hasSize(1); - assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); - assertThat(result.relations().get(0).targets()).hasSize(1); - assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); - } - - @Test - @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") - void shouldReturnFallbackNodeWhenTargetNotInMap() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses-db", "database", "missing-db"))); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relations()).hasSize(1); - EntityGraphNode fallback = result.relations().get(0).targets().get(0); - assertThat(fallback.identifier()).isEqualTo("missing-db"); - } + } + + // ======================== + @Nested + @DisplayName("Outbound Relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relation targets at depth 1") + void shouldResolveOutboundRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); } - // ======================== - @Nested - @DisplayName("Inbound Relations (relationsAsTarget)") - class InboundRelations { - - @Test - @DisplayName("Should resolve inbound relations when another entity points to root") - void shouldResolveInboundRelations() { - Entity api = entity(TEMPLATE, "api", "API Service"); - Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", - List.of(relation("depends-on", TEMPLATE, "api"))); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key(TEMPLATE, "consumer"), consumer)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relationsAsTarget()).hasSize(1); - assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); - assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()).isEqualTo("consumer"); - } - } + @Test + @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); - // ======================== - @Nested - @DisplayName("Depth Clamping") - class DepthClamping { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - @Test - @DisplayName("Should clamp depth below 1 to 1") - void shouldClampDepthBelowOne() { - Entity api = entity(TEMPLATE, "api", "API Service"); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); + assertThat(result.relations()).hasSize(1); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); + } + } + + // ======================== + @Nested + @DisplayName("Inbound Relations (relationsAsTarget)") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations when another entity points to root") + void shouldResolveInboundRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()) + .isEqualTo("consumer"); + } + } - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); - } + // ======================== + @Nested + @DisplayName("Depth Clamping") + class DepthClamping { - @Test - @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") - void shouldClampDepthAboveTen() { - Entity api = entity(TEMPLATE, "api", "API Service"); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); - } + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } - // ======================== - @Nested - @DisplayName("Depth Limit — Leaf Nodes at Boundary") - class DepthLimit { - - @Test - @DisplayName("Should return target as leaf node when depth limit is reached") - void shouldReturnLeafNodeAtDepthBoundary() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses-db", "database", "postgres"))); - Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", - List.of(relation("runs-on", "infra", "server-1"))); - Entity server = entity("infra", "server-1", "Server 1"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key("database", "postgres"), postgres, - key("infra", "server-1"), server)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); - assertThat(postgresNode.identifier()).isEqualTo("postgres"); - // At depth=1, postgres is a leaf — no further relations resolved - assertThat(postgresNode.relations()).isEmpty(); - assertThat(postgresNode.relationsAsTarget()).isEmpty(); - } - } + @Test + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") + void shouldClampDepthAboveTen() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - // ======================== - @Nested - @DisplayName("Multiple Named Relations") - class MultipleRelations { - - @Test - @DisplayName("Should resolve multiple distinct relation types") - void shouldResolveMultipleNamedRelations() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( - relation("uses-db", "database", "postgres"), - relation("depends-on", TEMPLATE, "auth"))); - Entity postgres = entity("database", "postgres", "Postgres DB"); - Entity auth = entity(TEMPLATE, "auth", "Auth Service"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key("database", "postgres"), postgres, - key(TEMPLATE, "auth"), auth)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relations()).hasSize(2); - assertThat(result.relations().stream().map(EntityGraphRelation::name)) - .containsExactlyInAnyOrder("uses-db", "depends-on"); - } - } + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); - // ======================== - @Nested - @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") - class FullGraphReturned { - - @Test - @DisplayName("Should return all edges regardless of relation type (no filtering in service)") - void shouldReturnAllEdgesWithoutFiltering() { - // A --(depends-on)--> B --(owns)--> C - // The service must return both edges — the mapper will filter them. - Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("depends-on", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", - List.of(relation("owns", TEMPLATE, "c"))); - Entity c = entity(TEMPLATE, "c", "C"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) - .thenReturn(Optional.of(a)); - stubGraph(Map.of( - key(TEMPLATE, "a"), a, - key(TEMPLATE, "b"), b, - key(TEMPLATE, "c"), c)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); - - // Root A has one outbound "depends-on" edge → B - assertThat(result.relations()).hasSize(1); - assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); - - // B (at depth 1) has one outbound "owns" edge → C - EntityGraphNode nodeB = result.relations().get(0).targets().get(0); - assertThat(nodeB.identifier()).isEqualTo("b"); - assertThat(nodeB.relations()).hasSize(1); - assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); - assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); - - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); - } + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); + } + } + + // ======================== + @Nested + @DisplayName("Depth Limit — Leaf Nodes at Boundary") + class DepthLimit { + + @Test + @DisplayName("Should return target as leaf node when depth limit is reached") + void shouldReturnLeafNodeAtDepthBoundary() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", + List.of(relation("runs-on", "infra", "server-1"))); + Entity server = entity("infra", "server-1", "Server 1"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, + key("infra", "server-1"), server)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further relations resolved + assertThat(postgresNode.relations()).isEmpty(); + assertThat(postgresNode.relationsAsTarget()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Multiple Named Relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple distinct relation types") + void shouldResolveMultipleNamedRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", "database", "postgres"), relation("depends-on", TEMPLATE, "auth"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity auth = entity(TEMPLATE, "auth", "Auth Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") + class FullGraphReturned { + + @Test + @DisplayName("Should return all edges regardless of relation type (no filtering in service)") + void shouldReturnAllEdgesWithoutFiltering() { + // A --(depends-on)--> B --(owns)--> C + // The service must return both edges — the mapper will filter them. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("owns", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + + // Root A has one outbound "depends-on" edge → B + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + + // B (at depth 1) has one outbound "owns" edge → C + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + assertThat(nodeB.relations()).hasSize(1); + assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); + assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + void shouldNotExplodeAtMaxDepthWithSmallGraph() { + // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound + // from B. + // Without the visited-node guard this produces O(2^depth) calls at depth=10. + Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + // Must complete instantly — any OOM or StackOverflow here means the guard is + // missing. + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + + assertThat(result.identifier()).isEqualTo("a"); + assertThat(result.relations()).hasSize(1); } - // ======================== - @Nested - @DisplayName("Visited Node Guard — OOM Prevention") - class VisitedNodeGuard { - - @Test - @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") - void shouldNotExplodeAtMaxDepthWithSmallGraph() { - // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound from B. - // Without the visited-node guard this produces O(2^depth) calls at depth=10. - Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("uses", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", - List.of(relation("uses", TEMPLATE, "c"))); - Entity c = entity(TEMPLATE, "c", "C"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) - .thenReturn(Optional.of(a)); - stubGraph(Map.of( - key(TEMPLATE, "a"), a, - key(TEMPLATE, "b"), b, - key(TEMPLATE, "c"), c)); - - // Must complete instantly — any OOM or StackOverflow here means the guard is missing. - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); - - assertThat(result.identifier()).isEqualTo("a"); - assertThat(result.relations()).hasSize(1); - } - - @Test - @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") - void shouldReturnStubLeafForRevisitedNode() { - // A --(uses)--> B; B also points back to A (cycle: A→B→A) - Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("uses", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", - List.of(relation("uses", TEMPLATE, "a"))); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) - .thenReturn(Optional.of(a)); - stubGraph(Map.of( - key(TEMPLATE, "a"), a, - key(TEMPLATE, "b"), b)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); - - // A → B is resolved - assertThat(result.relations()).hasSize(1); - EntityGraphNode nodeB = result.relations().get(0).targets().get(0); - assertThat(nodeB.identifier()).isEqualTo("b"); - - // B → A is a revisit: A was already marked visited, so it returns a stub leaf - // with no further outbound or inbound relations (no infinite loop). - EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); - assertThat(stubA.identifier()).isEqualTo("a"); - assertThat(stubA.relations()).isEmpty(); - assertThat(stubA.relationsAsTarget()).isEmpty(); - } + @Test + @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") + void shouldReturnStubLeafForRevisitedNode() { + // A --(uses)--> B; B also points back to A (cycle: A→B→A) + Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "a"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + + // A → B is resolved + assertThat(result.relations()).hasSize(1); + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + + // B → A is a revisit: A was already marked visited, so it returns a stub leaf + // with no further outbound or inbound relations (no infinite loop). + EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); + assertThat(stubA.identifier()).isEqualTo("a"); + assertThat(stubA.relations()).isEmpty(); + assertThat(stubA.relationsAsTarget()).isEmpty(); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java index 5d40663..a6cb317 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java @@ -32,241 +32,233 @@ @ExtendWith(MockitoExtension.class) class EntityTemplateServiceTest { - @Mock - private EntityTemplateRepositoryPort entityTemplateRepositoryPort; - @Mock - private EntityTemplateValidationService entityTemplateValidationService; - @Mock - private EntityRepositoryPort entityRepositoryPort; - - private EntityTemplateService entityTemplateService; - - @BeforeEach - void setUp() { - entityTemplateService = new EntityTemplateService( - entityTemplateRepositoryPort, - entityTemplateValidationService, - entityRepositoryPort); + @Mock + private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @Mock + private EntityRepositoryPort entityRepositoryPort; + + private EntityTemplateService entityTemplateService; + + @BeforeEach + void setUp() { + entityTemplateService = new EntityTemplateService(entityTemplateRepositoryPort, + entityTemplateValidationService, entityRepositoryPort); + } + + @Nested + @DisplayName("updateEntityTemplate - relation purge on definition removal") + class RelationPurgeTests { + + private static final UUID TEMPLATE_ID = UUID.randomUUID(); + private static final String TEMPLATE_IDENTIFIER = "web-service"; + + private EntityTemplate buildTemplate(List relations) { + return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", List.of(), + relations); } - @Nested - @DisplayName("updateEntityTemplate - relation purge on definition removal") - class RelationPurgeTests { - - private static final UUID TEMPLATE_ID = UUID.randomUUID(); - private static final String TEMPLATE_IDENTIFIER = "web-service"; - - private EntityTemplate buildTemplate(List relations) { - return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", List.of(), relations); - } - - @Test - @DisplayName("Should purge entity relations when a RelationDefinition is removed") - void shouldPurgeWhenRelationDefinitionRemoved() { - var existingRelation = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var existingTemplate = buildTemplate(List.of(existingRelation)); - var updatedTemplate = buildTemplate(List.of()); // "owns" removed - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deleteRelationsByTemplateIdentifierAndRelationName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 1 && c.contains("owns"))); - } - - @Test - @DisplayName("Should purge all removed relation names when multiple are removed") - void shouldPurgeAllRemovedRelations() { - var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); - var rel3 = new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false); - var existingTemplate = buildTemplate(List.of(rel1, rel2, rel3)); - // Only "belongsTo" is kept - var updatedTemplate = buildTemplate(List.of(rel3)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deleteRelationsByTemplateIdentifierAndRelationName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 2 - && c.contains("owns") - && c.contains("uses"))); - } - - @Test - @DisplayName("Should NOT call purge when no RelationDefinitions are removed") - void shouldNotPurgeWhenNoRelationsRemoved() { - var rel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var existingTemplate = buildTemplate(List.of(rel)); - var updatedTemplate = buildTemplate(List.of(rel)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); - } - - @Test - @DisplayName("Should NOT call purge when template had no relations") - void shouldNotPurgeWhenTemplateHadNoRelations() { - var existingTemplate = buildTemplate(List.of()); - var newRel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var updatedTemplate = buildTemplate(List.of(newRel)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); - } - - @Test - @DisplayName("Should match removed relations case-insensitively") - void shouldMatchRemovedRelationsCaseInsensitively() { - var rel = new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false); - var existingTemplate = buildTemplate(List.of(rel)); - // Incoming uses different casing but same logical name - var updatedRelation = new RelationDefinition(null, "owns", "microservice", true, false); - var updatedTemplate = buildTemplate(List.of(updatedRelation)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - // "Owns" and "owns" are the same — no removal, no purge call - verify(entityRepositoryPort, never()) - .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); - } + @Test + @DisplayName("Should purge entity relations when a RelationDefinition is removed") + void shouldPurgeWhenRelationDefinitionRemoved() { + var existingRelation = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, + false); + var existingTemplate = buildTemplate(List.of(existingRelation)); + var updatedTemplate = buildTemplate(List.of()); // "owns" removed + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort).deleteRelationsByTemplateIdentifierAndRelationName( + eq(TEMPLATE_IDENTIFIER), + argThat((Collection c) -> c.size() == 1 && c.contains("owns"))); } - @Nested - @DisplayName("updateEntityTemplate - property purge on definition removal") - class PropertyPurgeTests { - - private static final UUID TEMPLATE_ID = UUID.randomUUID(); - private static final String TEMPLATE_IDENTIFIER = "web-service"; - - private EntityTemplate buildTemplate(List properties) { - return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", properties, List.of()); - } - - private PropertyDefinition prop(String name) { - return new PropertyDefinition(UUID.randomUUID(), name, "desc", PropertyType.STRING, false, null); - } - - @Test - @DisplayName("Should purge entity properties when a PropertyDefinition is removed") - void shouldPurgeWhenPropertyDefinitionRemoved() { - var existingTemplate = buildTemplate(List.of(prop("color"))); - var updatedTemplate = buildTemplate(List.of()); // "color" removed - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 1 && c.contains("color"))); - } - - @Test - @DisplayName("Should purge all removed property names when multiple are removed") - void shouldPurgeAllRemovedProperties() { - var p1 = prop("color"); - var p2 = prop("port"); - var p3 = prop("env"); - var existingTemplate = buildTemplate(List.of(p1, p2, p3)); - var updatedTemplate = buildTemplate(List.of(p3)); // keep only "env" - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 2 - && c.contains("color") - && c.contains("port"))); - } - - @Test - @DisplayName("Should NOT call purge when no PropertyDefinitions are removed") - void shouldNotPurgeWhenNoPropertiesRemoved() { - var p = prop("color"); - var existingTemplate = buildTemplate(List.of(p)); - var updatedTemplate = buildTemplate(List.of(p)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); - } - - @Test - @DisplayName("Should NOT call purge when template had no properties") - void shouldNotPurgeWhenTemplateHadNoProperties() { - var existingTemplate = buildTemplate(List.of()); - var updatedTemplate = buildTemplate(List.of(prop("color"))); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); - } - - @Test - @DisplayName("Should match removed properties case-insensitively") - void shouldMatchRemovedPropertiesCaseInsensitively() { - var existingTemplate = buildTemplate(List.of( - new PropertyDefinition(UUID.randomUUID(), "Color", "desc", - PropertyType.STRING, false, null))); - // Incoming uses lowercase — same logical property - var updatedTemplate = buildTemplate(List.of( - new PropertyDefinition(null, "color", "desc", - PropertyType.STRING, false, null))); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - // "Color" and "color" are the same — no removal, no purge call - verify(entityRepositoryPort, never()) - .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); - } + @Test + @DisplayName("Should purge all removed relation names when multiple are removed") + void shouldPurgeAllRemovedRelations() { + var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); + var rel3 = new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false); + var existingTemplate = buildTemplate(List.of(rel1, rel2, rel3)); + // Only "belongsTo" is kept + var updatedTemplate = buildTemplate(List.of(rel3)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort) + .deleteRelationsByTemplateIdentifierAndRelationName(eq(TEMPLATE_IDENTIFIER), argThat( + (Collection c) -> c.size() == 2 && c.contains("owns") && c.contains("uses"))); + } + + @Test + @DisplayName("Should NOT call purge when no RelationDefinitions are removed") + void shouldNotPurgeWhenNoRelationsRemoved() { + var rel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var existingTemplate = buildTemplate(List.of(rel)); + var updatedTemplate = buildTemplate(List.of(rel)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); + } + + @Test + @DisplayName("Should NOT call purge when template had no relations") + void shouldNotPurgeWhenTemplateHadNoRelations() { + var existingTemplate = buildTemplate(List.of()); + var newRel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var updatedTemplate = buildTemplate(List.of(newRel)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); + } + + @Test + @DisplayName("Should match removed relations case-insensitively") + void shouldMatchRemovedRelationsCaseInsensitively() { + var rel = new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false); + var existingTemplate = buildTemplate(List.of(rel)); + // Incoming uses different casing but same logical name + var updatedRelation = new RelationDefinition(null, "owns", "microservice", true, false); + var updatedTemplate = buildTemplate(List.of(updatedRelation)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + // "Owns" and "owns" are the same — no removal, no purge call + verify(entityRepositoryPort, never()) + .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); + } + } + + @Nested + @DisplayName("updateEntityTemplate - property purge on definition removal") + class PropertyPurgeTests { + + private static final UUID TEMPLATE_ID = UUID.randomUUID(); + private static final String TEMPLATE_IDENTIFIER = "web-service"; + + private EntityTemplate buildTemplate(List properties) { + return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", properties, + List.of()); + } + + private PropertyDefinition prop(String name) { + return new PropertyDefinition(UUID.randomUUID(), name, "desc", PropertyType.STRING, false, + null); + } + + @Test + @DisplayName("Should purge entity properties when a PropertyDefinition is removed") + void shouldPurgeWhenPropertyDefinitionRemoved() { + var existingTemplate = buildTemplate(List.of(prop("color"))); + var updatedTemplate = buildTemplate(List.of()); // "color" removed + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( + eq(TEMPLATE_IDENTIFIER), + argThat((Collection c) -> c.size() == 1 && c.contains("color"))); + } + + @Test + @DisplayName("Should purge all removed property names when multiple are removed") + void shouldPurgeAllRemovedProperties() { + var p1 = prop("color"); + var p2 = prop("port"); + var p3 = prop("env"); + var existingTemplate = buildTemplate(List.of(p1, p2, p3)); + var updatedTemplate = buildTemplate(List.of(p3)); // keep only "env" + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( + eq(TEMPLATE_IDENTIFIER), argThat((Collection c) -> c.size() == 2 + && c.contains("color") && c.contains("port"))); + } + + @Test + @DisplayName("Should NOT call purge when no PropertyDefinitions are removed") + void shouldNotPurgeWhenNoPropertiesRemoved() { + var p = prop("color"); + var existingTemplate = buildTemplate(List.of(p)); + var updatedTemplate = buildTemplate(List.of(p)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); + } + + @Test + @DisplayName("Should NOT call purge when template had no properties") + void shouldNotPurgeWhenTemplateHadNoProperties() { + var existingTemplate = buildTemplate(List.of()); + var updatedTemplate = buildTemplate(List.of(prop("color"))); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); + } + + @Test + @DisplayName("Should match removed properties case-insensitively") + void shouldMatchRemovedPropertiesCaseInsensitively() { + var existingTemplate = buildTemplate(List.of(new PropertyDefinition(UUID.randomUUID(), + "Color", "desc", PropertyType.STRING, false, null))); + // Incoming uses lowercase — same logical property + var updatedTemplate = buildTemplate( + List.of(new PropertyDefinition(null, "color", "desc", PropertyType.STRING, false, null))); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + // "Color" and "color" are the same — no removal, no purge call + verify(entityRepositoryPort, never()) + .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java index daee862..098ecca 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java @@ -9,14 +9,14 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyFormat; @@ -25,1193 +25,715 @@ @DisplayName("PropertyDefinitionValidationService Tests") class PropertyDefinitionValidationServiceTest { - private PropertyDefinitionValidationService propertyDefinitionValidationService; + private PropertyDefinitionValidationService propertyDefinitionValidationService; + + @BeforeEach + void setUp() { + propertyDefinitionValidationService = new PropertyDefinitionValidationService( + new PropertyRegexValidationService()); + } + + @Nested + @DisplayName("STRING Property Type") + class StringPropertyTypeTests { + + @Test + @DisplayName("Happy path: STRING with format and max_length rules") + void testStringWithValidRules() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, null, + 255, 1, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "email", + "Email address", PropertyType.STRING, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length and max_length") + void testStringWithLengthConstraints() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 100, 10, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "description", + "A description", PropertyType.STRING, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with enum_values") + void testStringWithEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "status", "Status", + PropertyType.STRING, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with regex pattern") + void testStringWithRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, "^[a-zA-Z0-9]+$", null, + null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "username", + "Username", PropertyType.STRING, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: STRING with numeric max_value rule") + void testStringRejectsMaxValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 100, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "name", "Name", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("name")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with numeric min_value rule") + void testStringRejectsMinValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, null, + 0); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "counter", "Counter", + PropertyType.STRING, false, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("counter")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with min_length > max_length") + void testStringWithInvalidLengthConstraints() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 50, 100, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with negative min_length") + void testStringWithNegativeMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 255, -1, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("0")); + } + + @Test + @DisplayName("Error: STRING with invalid regex pattern") + void testStringWithInvalidRegexPattern() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, "[invalid-regex", 255, + null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("[invalid-regex")); + } + + @Test + @DisplayName("Happy path: STRING with null rules") + void testStringWithNullRules() { + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, null); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length = 0 and max_length > 0") + void testStringWithZeroMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 100, 0, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "optional_field", + "An optional field", PropertyType.STRING, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: STRING with max_length <= 0") + void testStringWithNonPositiveMaxLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 0, null, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("max_length")); + assertTrue(ex.getMessage().contains("greater than 0")); + } + + @Test + @DisplayName("Error: STRING with format and enum_values combined") + void testStringRejectsFormatWithEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, + List.of("EMAIL", "POSTAL_CODE"), null, null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact", + "Contact field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with format and regex combined") + void testStringRejectsFormatWithRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, + "^[a-zA-Z]+$", null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact", + "Contact field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: STRING with regex and enum_values combined") + void testStringRejectsRegexWithEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("ACTIVE", "INACTIVE"), "^[A-Z]+$", null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "status", + "Status field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with enum_values and max_length combined") + void testStringRejectsEnumValuesWithMaxLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("EMAIL", "POSTAL_CODE"), null, 12, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact_type", + "Contact type field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with enum_values and min_length combined") + void testStringRejectsEnumValuesWithMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("EMAIL", "POSTAL_CODE"), null, null, 3, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact_type", + "Contact type field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("min_length")); + } + } + + @Nested + @DisplayName("NUMBER Property Type") + class NumberPropertyTypeTests { + + @Test + @DisplayName("Happy path: NUMBER with min_value and max_value") + void testNumberWithValidRules() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 1000, + 0); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "score", + "Numeric score", PropertyType.NUMBER, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with only max_value") + void testNumberWithOnlyMaxValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 100, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "percentage", + "Percentage value", PropertyType.NUMBER, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: NUMBER with format rule") + void testNumberRejectsFormat() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, null, + null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "value", + "Numeric value", PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("value")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("format")); + } + + @Test + @DisplayName("Error: NUMBER with enum_values rule") + void testNumberRejectsEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, List.of("1", "2", "3"), null, + null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "category", + "Category", PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: NUMBER with regex rule") + void testNumberRejectsRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, "^[0-9]+$", null, null, + null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "id", "ID", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: NUMBER with min_length rule") + void testNumberRejectsMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, 5, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_length")); + } + + @Test + @DisplayName("Error: NUMBER with max_length rule") + void testNumberRejectsMaxLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 50, null, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: NUMBER with min_value > max_value") + void testNumberWithInvalidValueConstraints() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 0, + 100); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "range", "A range", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_value")); + assertTrue(ex.getMessage().contains("max_value")); + } + + @Test + @DisplayName("Happy path: NUMBER with only min_value") + void testNumberWithOnlyMinValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, null, + 10); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "minimum_age", + "Minimum age", PropertyType.NUMBER, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with negative min_value and max_value") + void testNumberWithNegativeValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 100, + -100); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "temperature", + "Temperature", PropertyType.NUMBER, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with null rules") + void testNumberWithNullRules() { + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "count", "A count", + PropertyType.NUMBER, true, null); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + } + + @Nested + @DisplayName("BOOLEAN Property Type") + class BooleanPropertyTypeTests { + + @Test + @DisplayName("Happy path: BOOLEAN with no rules") + void testBooleanWithNullRules() { + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "active", "Is active", + PropertyType.BOOLEAN, true, null); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: BOOLEAN with format rule") + void testBooleanRejectsFormat() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, null, + null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "enabled", "Enabled", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("rules")); + } + + @Test + @DisplayName("Error: BOOLEAN with enum_values rule") + void testBooleanRejectsEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, List.of("true", "false"), + null, null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "flag", "A flag", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with regex rule") + void testBooleanRejectsRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, ".*", null, null, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "test", "Test", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with min_value rule") + void testBooleanRejectsMinValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, null, + 0); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "valid", "Valid", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with max_value rule") + void testBooleanRejectsMaxValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 1, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "valid", "Valid", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + } + + @Nested + @DisplayName("validateUniquePropertyNames") + class ValidateUniquePropertyNamesTests { + + @Test + @DisplayName("Happy path: all property names are unique") + void testUniquePropertyNames() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, null), + new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, + null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + } + + @Test + @DisplayName("Happy path: empty property list") + void testEmptyPropertyList() { + assertDoesNotThrow(() -> propertyDefinitionValidationService + .validatePropertyNamesUniqueness(new ArrayList<>())); + } + + @Test + @DisplayName("Error: duplicate property names") + void testDuplicatePropertyNames() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "email", "Alternative Email", + PropertyType.STRING, false, null)); + + PropertyNameAlreadyExistsException ex = assertThrows(PropertyNameAlreadyExistsException.class, + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + assertTrue(ex.getMessage().contains("email")); + } + + @Test + @DisplayName("Error: multiple duplicates detected") + void testMultipleDuplicates() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "name", "Duplicate 1", PropertyType.STRING, + false, null), + new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "email", "Duplicate Email", PropertyType.STRING, + false, null)); + + PropertyNameAlreadyExistsException ex = assertThrows(PropertyNameAlreadyExistsException.class, + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + // Should fail on first duplicate found + assertTrue(ex.getMessage().contains("name")); + } + + @Test + @DisplayName("Error: case-insensitive duplicates (Name vs name)") + void testCaseInsensitiveDuplicates() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "applicationName", "Application Name", + PropertyType.STRING, true, null), + new PropertyDefinition(UUID.randomUUID(), "applicationname", + "Application Name (lowercase)", PropertyType.STRING, false, null)); + + PropertyNameAlreadyExistsException ex = assertThrows(PropertyNameAlreadyExistsException.class, + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + assertTrue(ex.getMessage().contains("applicationname")); + } + } + + @Nested + @DisplayName("validateTypeChanges") + class ValidateTypeChangesTests { + + @Test + @DisplayName("Happy path: no existing properties") + void testNoExistingProperties() { + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "name", + "Name", PropertyType.STRING, true, null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(null, updated)); + } + + @Test + @DisplayName("Happy path: no type changes") + void testNoTypeChanges() { + UUID propertyId = UUID.randomUUID(); + List existing = List + .of(new PropertyDefinition(propertyId, "name", "Name", PropertyType.STRING, true, null)); + List updated = List.of(new PropertyDefinition(propertyId, "name", + "Updated Name", PropertyType.STRING, false, null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + } + + @Test + @DisplayName("Error: conversion NUMBER to STRING is forbidden") + void testConversionNumberToStringForbidden() { + List existing = List.of( + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, true, null)); + List updated = List.of( + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.STRING, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("age")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: conversion BOOLEAN to STRING is forbidden") + void testConversionBooleanToStringForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), + "active", "Active", PropertyType.BOOLEAN, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "active", + "Active", PropertyType.STRING, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("active")); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("STRING")); + } - @BeforeEach - void setUp() { - propertyDefinitionValidationService = new PropertyDefinitionValidationService(new PropertyRegexValidationService()); + @Test + @DisplayName("Error: any type conversion STRING to NUMBER is forbidden") + void testConversionStringToNumberForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), "code", + "Code", PropertyType.STRING, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "code", + "Code", PropertyType.NUMBER, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("code")); + assertTrue(ex.getMessage().contains("STRING")); + assertTrue(ex.getMessage().contains("NUMBER")); } - @Nested - @DisplayName("STRING Property Type") - class StringPropertyTypeTests { - - @Test - @DisplayName("Happy path: STRING with format and max_length rules") - void testStringWithValidRules() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - null, - 255, - 1, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "email", - "Email address", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with min_length and max_length") - void testStringWithLengthConstraints() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 100, - 10, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "description", - "A description", - PropertyType.STRING, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with enum_values") - void testStringWithEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("ACTIVE", "INACTIVE"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "status", - "Status", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with regex pattern") - void testStringWithRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "^[a-zA-Z0-9]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "username", - "Username", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: STRING with numeric max_value rule") - void testStringRejectsMaxValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 100, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "name", - "Name", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("name")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: STRING with numeric min_value rule") - void testStringRejectsMinValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - null, - 0 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "counter", - "Counter", - PropertyType.STRING, - false, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("counter")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: STRING with min_length > max_length") - void testStringWithInvalidLengthConstraints() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 50, - 100, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_length")); - assertTrue(ex.getMessage().contains("max_length")); - } - - @Test - @DisplayName("Error: STRING with negative min_length") - void testStringWithNegativeMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 255, - -1, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_length")); - assertTrue(ex.getMessage().contains("0")); - } - - @Test - @DisplayName("Error: STRING with invalid regex pattern") - void testStringWithInvalidRegexPattern() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "[invalid-regex", - 255, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("regex")); - assertTrue(ex.getMessage().contains("[invalid-regex")); - } - - @Test - @DisplayName("Happy path: STRING with null rules") - void testStringWithNullRules() { - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - null - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with min_length = 0 and max_length > 0") - void testStringWithZeroMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 100, - 0, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "optional_field", - "An optional field", - PropertyType.STRING, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: STRING with max_length <= 0") - void testStringWithNonPositiveMaxLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 0, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("max_length")); - assertTrue(ex.getMessage().contains("greater than 0")); - } - - @Test - @DisplayName("Error: STRING with format and enum_values combined") - void testStringRejectsFormatWithEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - List.of("EMAIL", "POSTAL_CODE"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact", - "Contact field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("format")); - assertTrue(ex.getMessage().contains("enum_values")); - } - - @Test - @DisplayName("Error: STRING with format and regex combined") - void testStringRejectsFormatWithRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - "^[a-zA-Z]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact", - "Contact field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("format")); - assertTrue(ex.getMessage().contains("regex")); - } - - @Test - @DisplayName("Error: STRING with regex and enum_values combined") - void testStringRejectsRegexWithEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("ACTIVE", "INACTIVE"), - "^[A-Z]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "status", - "Status field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("regex")); - assertTrue(ex.getMessage().contains("enum_values")); - } - - @Test - @DisplayName("Error: STRING with enum_values and max_length combined") - void testStringRejectsEnumValuesWithMaxLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("EMAIL", "POSTAL_CODE"), - null, - 12, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact_type", - "Contact type field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("enum_values")); - assertTrue(ex.getMessage().contains("max_length")); - } - - @Test - @DisplayName("Error: STRING with enum_values and min_length combined") - void testStringRejectsEnumValuesWithMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("EMAIL", "POSTAL_CODE"), - null, - null, - 3, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact_type", - "Contact type field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("enum_values")); - assertTrue(ex.getMessage().contains("min_length")); - } + @Test + @DisplayName("Error: any type conversion NUMBER to BOOLEAN is forbidden") + void testConversionNumberToBooleanForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), "count", + "Count", PropertyType.NUMBER, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "count", + "Count", PropertyType.BOOLEAN, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("count")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("BOOLEAN")); } - @Nested - @DisplayName("NUMBER Property Type") - class NumberPropertyTypeTests { - - @Test - @DisplayName("Happy path: NUMBER with min_value and max_value") - void testNumberWithValidRules() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 1000, - 0 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "score", - "Numeric score", - PropertyType.NUMBER, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: NUMBER with only max_value") - void testNumberWithOnlyMaxValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 100, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "percentage", - "Percentage value", - PropertyType.NUMBER, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: NUMBER with format rule") - void testNumberRejectsFormat() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "value", - "Numeric value", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("value")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertTrue(ex.getMessage().contains("format")); - } - - @Test - @DisplayName("Error: NUMBER with enum_values rule") - void testNumberRejectsEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("1", "2", "3"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "category", - "Category", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("enum_values")); - } - - @Test - @DisplayName("Error: NUMBER with regex rule") - void testNumberRejectsRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "^[0-9]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "id", - "ID", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("regex")); - } - - @Test - @DisplayName("Error: NUMBER with min_length rule") - void testNumberRejectsMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - 5, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_length")); - } - - @Test - @DisplayName("Error: NUMBER with max_length rule") - void testNumberRejectsMaxLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 50, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("max_length")); - } - - @Test - @DisplayName("Error: NUMBER with min_value > max_value") - void testNumberWithInvalidValueConstraints() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 0, - 100 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "range", - "A range", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_value")); - assertTrue(ex.getMessage().contains("max_value")); - } - - @Test - @DisplayName("Happy path: NUMBER with only min_value") - void testNumberWithOnlyMinValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - null, - 10 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "minimum_age", - "Minimum age", - PropertyType.NUMBER, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: NUMBER with negative min_value and max_value") - void testNumberWithNegativeValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 100, - -100 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "temperature", - "Temperature", - PropertyType.NUMBER, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: NUMBER with null rules") - void testNumberWithNullRules() { - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "count", - "A count", - PropertyType.NUMBER, - true, - null - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } + @Test + @DisplayName("Error: any type conversion BOOLEAN to NUMBER is forbidden") + void testConversionBooleanToNumberForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), + "active", "Active", PropertyType.BOOLEAN, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "active", + "Active", PropertyType.NUMBER, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("active")); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("NUMBER")); } - @Nested - @DisplayName("BOOLEAN Property Type") - class BooleanPropertyTypeTests { - - @Test - @DisplayName("Happy path: BOOLEAN with no rules") - void testBooleanWithNullRules() { - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "active", - "Is active", - PropertyType.BOOLEAN, - true, - null - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: BOOLEAN with format rule") - void testBooleanRejectsFormat() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "enabled", - "Enabled", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - assertTrue(ex.getMessage().contains("rules")); - } - - @Test - @DisplayName("Error: BOOLEAN with enum_values rule") - void testBooleanRejectsEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("true", "false"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "flag", - "A flag", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: BOOLEAN with regex rule") - void testBooleanRejectsRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - ".*", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "test", - "Test", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: BOOLEAN with min_value rule") - void testBooleanRejectsMinValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - null, - 0 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "valid", - "Valid", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: BOOLEAN with max_value rule") - void testBooleanRejectsMaxValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 1, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "valid", - "Valid", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } + @Test + @DisplayName("Happy path: property removed from updated list") + void testPropertyRemoved() { + List existing = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, + null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "name", + "Name", PropertyType.STRING, true, null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); } - @Nested - @DisplayName("validateUniquePropertyNames") - class ValidateUniquePropertyNamesTests { - - @Test - @DisplayName("Happy path: all property names are unique") - void testUniquePropertyNames() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, null), - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); - } - - @Test - @DisplayName("Happy path: empty property list") - void testEmptyPropertyList() { - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(new ArrayList<>())); - } - - @Test - @DisplayName("Error: duplicate property names") - void testDuplicatePropertyNames() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Alternative Email", PropertyType.STRING, false, null) - ); - - PropertyNameAlreadyExistsException ex = assertThrows( - PropertyNameAlreadyExistsException.class, - () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties) - ); - assertTrue(ex.getMessage().contains("email")); - } - - @Test - @DisplayName("Error: multiple duplicates detected") - void testMultipleDuplicates() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "name", "Duplicate 1", PropertyType.STRING, false, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Duplicate Email", PropertyType.STRING, false, null) - ); - - PropertyNameAlreadyExistsException ex = assertThrows( - PropertyNameAlreadyExistsException.class, - () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties) - ); - // Should fail on first duplicate found - assertTrue(ex.getMessage().contains("name")); - } - - @Test - @DisplayName("Error: case-insensitive duplicates (Name vs name)") - void testCaseInsensitiveDuplicates() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "applicationName", "Application Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "applicationname", "Application Name (lowercase)", PropertyType.STRING, false, null) - ); - - PropertyNameAlreadyExistsException ex = assertThrows( - PropertyNameAlreadyExistsException.class, - () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties) - ); - assertTrue(ex.getMessage().contains("applicationname")); - } + @Test + @DisplayName("Happy path: new property added to updated list") + void testPropertyAdded() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), "name", + "Name", PropertyType.STRING, true, null)); + List updated = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, false, + null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); } - @Nested - @DisplayName("validateTypeChanges") - class ValidateTypeChangesTests { - - @Test - @DisplayName("Happy path: no existing properties") - void testNoExistingProperties() { - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(null, updated)); - } - - @Test - @DisplayName("Happy path: no type changes") - void testNoTypeChanges() { - UUID propertyId = UUID.randomUUID(); - List existing = List.of( - new PropertyDefinition(propertyId, "name", "Name", PropertyType.STRING, true, null) - ); - List updated = List.of( - new PropertyDefinition(propertyId, "name", "Updated Name", PropertyType.STRING, false, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); - } - - @Test - @DisplayName("Error: conversion NUMBER to STRING is forbidden") - void testConversionNumberToStringForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.STRING, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("age")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: conversion BOOLEAN to STRING is forbidden") - void testConversionBooleanToStringForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.STRING, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("active")); - assertTrue(ex.getMessage().contains("BOOLEAN")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: any type conversion STRING to NUMBER is forbidden") - void testConversionStringToNumberForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "code", "Code", PropertyType.STRING, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "code", "Code", PropertyType.NUMBER, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("code")); - assertTrue(ex.getMessage().contains("STRING")); - assertTrue(ex.getMessage().contains("NUMBER")); - } - - @Test - @DisplayName("Error: any type conversion NUMBER to BOOLEAN is forbidden") - void testConversionNumberToBooleanForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "count", "Count", PropertyType.NUMBER, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "count", "Count", PropertyType.BOOLEAN, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("count")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: any type conversion BOOLEAN to NUMBER is forbidden") - void testConversionBooleanToNumberForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.NUMBER, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("active")); - assertTrue(ex.getMessage().contains("BOOLEAN")); - assertTrue(ex.getMessage().contains("NUMBER")); - } - - @Test - @DisplayName("Happy path: property removed from updated list") - void testPropertyRemoved() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); - } - - @Test - @DisplayName("Happy path: new property added to updated list") - void testPropertyAdded() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, false, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); - } - - @Test - @DisplayName("Error: multiple type conversions forbidden, fails on first") - void testMultipleTypeConversionsForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.NUMBER, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.NUMBER, true, null), - new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.BOOLEAN, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("field1")); - assertTrue(ex.getMessage().contains("STRING")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertFalse(ex.getMessage().contains("BOOLEAN")); - } + @Test + @DisplayName("Error: multiple type conversions forbidden, fails on first") + void testMultipleTypeConversionsForbidden() { + List existing = List.of( + new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.NUMBER, true, + null)); + List updated = List.of( + new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.NUMBER, true, + null), + new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.BOOLEAN, true, + null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("field1")); + assertTrue(ex.getMessage().contains("STRING")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertFalse(ex.getMessage().contains("BOOLEAN")); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java index 1e9827f..31e8629 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java @@ -15,69 +15,65 @@ @DisplayName("PropertyRegexValidationService Tests") class PropertyRegexValidationServiceTest { - private PropertyRegexValidationService propertyRegexValidationService; + private PropertyRegexValidationService propertyRegexValidationService; - @BeforeEach - void setUp() { - propertyRegexValidationService = new PropertyRegexValidationService(); - } + @BeforeEach + void setUp() { + propertyRegexValidationService = new PropertyRegexValidationService(); + } - @ParameterizedTest - @ValueSource(strings = { - "^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", // email-like pattern - "^(foo|bar)$", // safe alternation, not quantified - "a{1,999}", // safe repetition bound - "^[a-zA-Z0-9_-]+$", // alphanumeric slug - "^\\d{4}-\\d{2}-\\d{2}$" // ISO date - }) - @DisplayName("Happy path: safe regex patterns are accepted") - void testSafeRegexPatternsAccepted(String safePattern) { - assertDoesNotThrow(() -> propertyRegexValidationService.validateRegexPattern("field", safePattern)); - } + @ParameterizedTest + @ValueSource(strings = {"^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", // email-like pattern + "^(foo|bar)$", // safe alternation, not quantified + "a{1,999}", // safe repetition bound + "^[a-zA-Z0-9_-]+$", // alphanumeric slug + "^\\d{4}-\\d{2}-\\d{2}$" // ISO date + }) + @DisplayName("Happy path: safe regex patterns are accepted") + void testSafeRegexPatternsAccepted(String safePattern) { + assertDoesNotThrow( + () -> propertyRegexValidationService.validateRegexPattern("field", safePattern)); + } - @Test - @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") - void testRegexPatternTooLong() { - String longPattern = "a".repeat(1001); - String propertyName = "field"; + @Test + @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") + void testRegexPatternTooLong() { + String longPattern = "a".repeat(1001); + String propertyName = "field"; - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern) - ); - assertTrue(ex.getMessage().contains("too long")); - assertTrue(ex.getMessage().contains("1000")); - } + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern)); + assertTrue(ex.getMessage().contains("too long")); + assertTrue(ex.getMessage().contains("1000")); + } - @ParameterizedTest - @ValueSource(strings = { - "(a+)+", // nested quantifiers with + - "(a*)*", // nested quantifiers with * - "(a+)*", // mixed nested quantifiers - "(a|b)+", // quantified alternation with + - "(foo|bar)*", // quantified alternation with * - "a{1,5000}" // excessive repetition bound - }) - @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") - void testRegexWithDangerousPatterns(String dangerousPattern) { - String propertyName = "field"; + @ParameterizedTest + @ValueSource(strings = {"(a+)+", // nested quantifiers with + + "(a*)*", // nested quantifiers with * + "(a+)*", // mixed nested quantifiers + "(a|b)+", // quantified alternation with + + "(foo|bar)*", // quantified alternation with * + "a{1,5000}" // excessive repetition bound + }) + @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") + void testRegexWithDangerousPatterns(String dangerousPattern) { + String propertyName = "field"; - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyRegexValidationService.validateRegexPattern(propertyName, dangerousPattern) - ); - assertTrue(ex.getMessage().contains("unsafe"), - "Expected 'unsafe' in error message for pattern: " + dangerousPattern); - } + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, dangerousPattern)); + assertTrue(ex.getMessage().contains("unsafe"), + "Expected 'unsafe' in error message for pattern: " + dangerousPattern); + } - @Test - @DisplayName("Error: Regex with invalid syntax") - void testRegexWithInvalidSyntax() { - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyRegexValidationService.validateRegexPattern("field", "[unclosed-bracket") - ); - assertTrue(ex.getMessage().contains("Invalid regex")); - } + @Test + @DisplayName("Error: Regex with invalid syntax") + void testRegexWithInvalidSyntax() { + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern("field", "[unclosed-bracket")); + assertTrue(ex.getMessage().contains("Invalid regex")); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java index 4d9513d..8c29ba0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java @@ -28,318 +28,292 @@ @ExtendWith(MockitoExtension.class) class RelationDefinitionValidationServiceTest { - @Mock - private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + @Mock + private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + + private RelationDefinitionValidationService relationDefinitionValidationService; + + @BeforeEach + void setUp() { + relationDefinitionValidationService = new RelationDefinitionValidationService( + entityTemplateRepositoryPort); + } + + @Nested + @DisplayName("validateUniqueRelationNames") + class ValidateUniqueRelationNamesTests { + + @Test + @DisplayName("Happy path: all relation names are unique") + void testUniqueRelationNames() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false)); + + assertDoesNotThrow( + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + } + + @Test + @DisplayName("Happy path: single relation") + void testSingleRelation() { + List relations = List + .of(new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false)); + + assertDoesNotThrow( + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + } + + @Test + @DisplayName("Happy path: empty relation list") + void testEmptyRelationList() { + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNamesUniqueness(new ArrayList<>())); + } + + @Test + @DisplayName("Error: duplicate relation names") + void testDuplicateRelationNames() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent-template", false, + false)); + + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + assertTrue(ex.getMessage().contains("parent")); + } + + @Test + @DisplayName("Error: multiple duplicates detected on first occurrence") + void testMultipleDuplicates() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "parent", "duplicate-parent", true, false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "children", "duplicate-children", false, true)); + + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + // Should fail on first duplicate found + assertTrue(ex.getMessage().contains("parent")); + } + + @Test + @DisplayName("Error: case-insensitive name comparison (Parent vs parent)") + void testCaseInsensitiveNames() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "Parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent", false, false)); + + // "Parent" and "parent" should now be treated as duplicates (case-insensitive) + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + assertTrue(ex.getMessage().contains("parent")); + } + + @Test + @DisplayName("Error: duplicate relation names with different cardinalities") + void testDuplicateNamesWithDifferentCardinalities() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "items", "item-template", true, false), + new RelationDefinition(UUID.randomUUID(), "items", "item-template", false, true)); + + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + assertTrue(ex.getMessage().contains("items")); + } + } + + @Nested + @DisplayName("validateTargetTemplatesExist") + class ValidateTargetTemplatesExistTests { + + @Test + @DisplayName("Happy path: all target templates exist") + void testAllTargetTemplatesExist() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false)); + + when(entityTemplateRepositoryPort.existsByIdentifier("parent-template")).thenReturn(true); + when(entityTemplateRepositoryPort.existsByIdentifier("child-template")).thenReturn(true); + when(entityTemplateRepositoryPort.existsByIdentifier("owner-template")).thenReturn(true); + + assertDoesNotThrow( + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + } + + @Test + @DisplayName("Happy path: empty relation list") + void testEmptyRelationList() { + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplatesExist(new ArrayList<>())); + } + + @Test + @DisplayName("Error: single relation with non-existent target") + void testSingleRelationWithNonExistentTarget() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "owner", "non-existent-template", true, false)); - private RelationDefinitionValidationService relationDefinitionValidationService; + when(entityTemplateRepositoryPort.existsByIdentifier("non-existent-template")) + .thenReturn(false); - @BeforeEach - void setUp() { - relationDefinitionValidationService = new RelationDefinitionValidationService(entityTemplateRepositoryPort); + TargetTemplateNotFoundException ex = assertThrows(TargetTemplateNotFoundException.class, + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + assertTrue(ex.getMessage().contains("non-existent-template")); } - @Nested - @DisplayName("validateUniqueRelationNames") - class ValidateUniqueRelationNamesTests { - - @Test - @DisplayName("Happy path: all relation names are unique") - void testUniqueRelationNames() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false) - ); - - assertDoesNotThrow(() -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); - } - - @Test - @DisplayName("Happy path: single relation") - void testSingleRelation() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false) - ); - - assertDoesNotThrow(() -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); - } - - @Test - @DisplayName("Happy path: empty relation list") - void testEmptyRelationList() { - assertDoesNotThrow(() -> relationDefinitionValidationService.validateRelationNamesUniqueness(new ArrayList<>())); - } - - @Test - @DisplayName("Error: duplicate relation names") - void testDuplicateRelationNames() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent-template", false, false) - ); - - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - assertTrue(ex.getMessage().contains("parent")); - } - - @Test - @DisplayName("Error: multiple duplicates detected on first occurrence") - void testMultipleDuplicates() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "parent", "duplicate-parent", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "children", "duplicate-children", false, true) - ); - - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - // Should fail on first duplicate found - assertTrue(ex.getMessage().contains("parent")); - } - - @Test - @DisplayName("Error: case-insensitive name comparison (Parent vs parent)") - void testCaseInsensitiveNames() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "Parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent", false, false) - ); - - // "Parent" and "parent" should now be treated as duplicates (case-insensitive) - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - assertTrue(ex.getMessage().contains("parent")); - } - - @Test - @DisplayName("Error: duplicate relation names with different cardinalities") - void testDuplicateNamesWithDifferentCardinalities() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "items", "item-template", true, false), - new RelationDefinition(UUID.randomUUID(), "items", "item-template", false, true) - ); - - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - assertTrue(ex.getMessage().contains("items")); - } + @Test + @DisplayName("Error: multiple relations with multiple targets missing") + void testMultipleTargetsNotFound() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "missing-parent-template", true, + false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "owner", "missing-owner-template", true, + false)); + + when(entityTemplateRepositoryPort.existsByIdentifier("missing-parent-template")) + .thenReturn(false); + + TargetTemplateNotFoundException ex = assertThrows(TargetTemplateNotFoundException.class, + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + // Should fail on first missing target + assertTrue(ex.getMessage().contains("missing-parent-template")); + } + + @Test + @DisplayName("Error: relation with null target identifier") + void testRelationWithNullTargetIdentifier() { + List relations = List + .of(new RelationDefinition(UUID.randomUUID(), "optional-parent", null, false, false)); + + TargetTemplateNotFoundException ex = assertThrows(TargetTemplateNotFoundException.class, + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("target")); + } + } + + @Nested + @DisplayName("validateTargetTemplateIdentifierChanges") + class ValidateTargetTemplateIdentifierChangesTests { + + @Test + @DisplayName("Happy path: no relations change targetTemplateIdentifier") + void testNoTargetTemplateChange() { + var existing = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false)); + var incoming = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", false, true), + new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", true, false)); + + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + } + + @Test + @DisplayName("Happy path: new relation added without changing existing targets") + void testNewRelationAdded() { + var existing = List + .of(new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false)); + var incoming = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true)); + + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + } + + @Test + @DisplayName("Happy path: existing relation removed from incoming list") + void testExistingRelationRemoved() { + var existing = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true)); + var incoming = List + .of(new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false)); + + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + } + + @Test + @DisplayName("Error: changing targetTemplateIdentifier on existing relation") + void testTargetTemplateIdentifierChanged() { + var existing = List + .of(new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false)); + var incoming = List + .of(new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false)); + + RelationTargetTemplateChangeException ex = assertThrows( + RelationTargetTemplateChangeException.class, () -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + assertTrue(ex.getMessage().contains("Owns")); + assertTrue(ex.getMessage().contains("microservice")); + assertTrue(ex.getMessage().contains("batch-job")); + } + + @Test + @DisplayName("Error: fails on first relation with changed target when multiple relations changed") + void testFailsOnFirstChangedTarget() { + var existing = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true)); + var incoming = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "other-service", false, true)); + + RelationTargetTemplateChangeException ex = assertThrows( + RelationTargetTemplateChangeException.class, () -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + assertTrue(ex.getMessage().contains("owns") || ex.getMessage().contains("uses")); + } + } + + @Nested + @DisplayName("validateNoSelfReference") + class ValidateNoSelfReferenceTests { + + @Test + @DisplayName("Happy path: no relations — no exception") + void noRelations() { + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNoSelfReference("my-template", List.of())); } - @Nested - @DisplayName("validateTargetTemplatesExist") - class ValidateTargetTemplatesExistTests { - - @Test - @DisplayName("Happy path: all target templates exist") - void testAllTargetTemplatesExist() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false) - ); - - when(entityTemplateRepositoryPort.existsByIdentifier("parent-template")).thenReturn(true); - when(entityTemplateRepositoryPort.existsByIdentifier("child-template")).thenReturn(true); - when(entityTemplateRepositoryPort.existsByIdentifier("owner-template")).thenReturn(true); - - assertDoesNotThrow(() -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); - } - - @Test - @DisplayName("Happy path: empty relation list") - void testEmptyRelationList() { - assertDoesNotThrow(() -> relationDefinitionValidationService.validateTargetTemplatesExist(new ArrayList<>())); - } - - @Test - @DisplayName("Error: single relation with non-existent target") - void testSingleRelationWithNonExistentTarget() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "owner", "non-existent-template", true, false) - ); - - when(entityTemplateRepositoryPort.existsByIdentifier("non-existent-template")).thenReturn(false); - - TargetTemplateNotFoundException ex = assertThrows( - TargetTemplateNotFoundException.class, - () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations) - ); - assertTrue(ex.getMessage().contains("non-existent-template")); - } - - @Test - @DisplayName("Error: multiple relations with multiple targets missing") - void testMultipleTargetsNotFound() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "missing-parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "owner", "missing-owner-template", true, false) - ); - - when(entityTemplateRepositoryPort.existsByIdentifier("missing-parent-template")).thenReturn(false); - - TargetTemplateNotFoundException ex = assertThrows( - TargetTemplateNotFoundException.class, - () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations) - ); - // Should fail on first missing target - assertTrue(ex.getMessage().contains("missing-parent-template")); - } - - @Test - @DisplayName("Error: relation with null target identifier") - void testRelationWithNullTargetIdentifier() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "optional-parent", null, false, false) - ); - - TargetTemplateNotFoundException ex = assertThrows( - TargetTemplateNotFoundException.class, - () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations) - ); - assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("target")); - } + @Test + @DisplayName("Happy path: null template identifier — no exception") + void nullTemplateIdentifier() { + var rel = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNoSelfReference(null, List.of(rel))); } - @Nested - @DisplayName("validateTargetTemplateIdentifierChanges") - class ValidateTargetTemplateIdentifierChangesTests { - - @Test - @DisplayName("Happy path: no relations change targetTemplateIdentifier") - void testNoTargetTemplateChange() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", false, true), - new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", true, false) - ); - - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming)); - } - - @Test - @DisplayName("Happy path: new relation added without changing existing targets") - void testNewRelationAdded() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true) - ); - - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming)); - } - - @Test - @DisplayName("Happy path: existing relation removed from incoming list") - void testExistingRelationRemoved() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false) - ); - - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming)); - } - - @Test - @DisplayName("Error: changing targetTemplateIdentifier on existing relation") - void testTargetTemplateIdentifierChanged() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false) - ); - - RelationTargetTemplateChangeException ex = assertThrows( - RelationTargetTemplateChangeException.class, - () -> relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming) - ); - assertTrue(ex.getMessage().contains("Owns")); - assertTrue(ex.getMessage().contains("microservice")); - assertTrue(ex.getMessage().contains("batch-job")); - } - - @Test - @DisplayName("Error: fails on first relation with changed target when multiple relations changed") - void testFailsOnFirstChangedTarget() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "other-service", false, true) - ); - - RelationTargetTemplateChangeException ex = assertThrows( - RelationTargetTemplateChangeException.class, - () -> relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming) - ); - assertTrue(ex.getMessage().contains("owns") || ex.getMessage().contains("uses")); - } + @Test + @DisplayName("Happy path: relations target different templates — no exception") + void relationsTargetOtherTemplates() { + var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNoSelfReference("my-template", List.of(rel1, rel2))); } - @Nested - @DisplayName("validateNoSelfReference") - class ValidateNoSelfReferenceTests { - - @Test - @DisplayName("Happy path: no relations — no exception") - void noRelations() { - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateRelationNoSelfReference("my-template", List.of())); - } - - @Test - @DisplayName("Happy path: null template identifier — no exception") - void nullTemplateIdentifier() { - var rel = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateRelationNoSelfReference(null, List.of(rel))); - } - - @Test - @DisplayName("Happy path: relations target different templates — no exception") - void relationsTargetOtherTemplates() { - var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateRelationNoSelfReference("my-template", List.of(rel1, rel2))); - } - - @Test - @DisplayName("Error: one of multiple relations targets its own template") - void oneOfMultipleRelationsTargetsSelf() { - var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); - var rel2 = new RelationDefinition(UUID.randomUUID(), "circular", "my-template", false, false); - var relations = List.of(rel1, rel2); - - RelationCannotTargetItselfException ex = assertThrows( - RelationCannotTargetItselfException.class, - () -> relationDefinitionValidationService.validateRelationNoSelfReference("my-template", relations) - ); - assertTrue(ex.getMessage().contains("circular") && ex.getMessage().contains("my-template")); - } + @Test + @DisplayName("Error: one of multiple relations targets its own template") + void oneOfMultipleRelationsTargetsSelf() { + var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); + var rel2 = new RelationDefinition(UUID.randomUUID(), "circular", "my-template", false, false); + var relations = List.of(rel1, rel2); + + RelationCannotTargetItselfException ex = assertThrows( + RelationCannotTargetItselfException.class, () -> relationDefinitionValidationService + .validateRelationNoSelfReference("my-template", relations)); + assertTrue(ex.getMessage().contains("circular") && ex.getMessage().contains("my-template")); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 14ed3f6..19dffe3 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -19,318 +19,349 @@ @DisplayName("PropertyValidationService Tests") class PropertyValidationServiceTest { - private final PropertyValidationService service = new PropertyValidationService(); + private final PropertyValidationService service = new PropertyValidationService(); - @Nested - @DisplayName("STRING validation") - class StringValidationTests { + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - @Test - @DisplayName("Should report type mismatch when STRING value is null") - void shouldReportTypeMismatchWhenStringValueIsNull() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null); + var violations = service.validatePropertyValue(definition, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), + violations); + } - @Test - @DisplayName("Should return no violations when STRING has no rules") - void shouldReturnNoViolationsWhenStringHasNoRules() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, "hello"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should return no violations when STRING value satisfies all rules") - void shouldReturnNoViolationsWhenStringPassesAllRules() { - var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); - var definition = propertyDefinition("env", PropertyType.STRING, rules); + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, + null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "dev"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minLength violation") - void shouldReportMinLengthViolation() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "ab"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should report maxLength violation") - void shouldReportMaxLengthViolation() { - var rules = new PropertyRules(null, null, null, null, 5, null, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should report regex violation") - void shouldReportRegexViolation() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "abc"); - assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), + violations); + } - @Test - @DisplayName("Should accept value matching regex") - void shouldAcceptValueMatchingRegex() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "12345"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report enum violation when value not in allowed list") - void shouldReportEnumViolation() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); - assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", + List.of("ACTIVE", "INACTIVE"))), violations); + } - @Test - @DisplayName("Should accept enum value with case-insensitive match") - void shouldAcceptEnumValueCaseInsensitive() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "active"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should skip enum check when enumValues is empty") - void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { - var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "anything"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report format violation for invalid EMAIL") - void shouldReportFormatViolationForInvalidEmail() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); - } + assertEquals(List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), + violations); + } - @Test - @DisplayName("Should accept valid EMAIL format") - void shouldAcceptValidEmailFormat() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report format violation for invalid URL") - void shouldReportFormatViolationForInvalidUrl() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), + violations); + } - @Test - @DisplayName("Should accept valid URL format") - void shouldAcceptValidUrlFormat() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report multiple violations at once") - void shouldReportMultipleStringViolations() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", + 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "AA"); - assertEquals(4, violations.size()); - } + assertEquals(4, violations.size()); + } - @Test - @DisplayName("Should use cached pattern for repeated regex validations") - void shouldUseCachedPatternForRepeatedRegex() { - var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); - assertEquals(List.of(), violations1); - assertEquals(List.of(), violations2); - } + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); } + } - @Nested - @DisplayName("NUMBER validation") - class NumberValidationTests { + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { - @Test - @DisplayName("Should report type mismatch for non-numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), + violations); + } - @Test - @DisplayName("Should return no violations when NUMBER has no rules") - void shouldReturnNoViolationsWhenNumberHasNoRules() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should return no violations when NUMBER is within bounds") - void shouldReturnNoViolationsWhenNumberIsWithinBounds() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minValue violation") - void shouldReportMinValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), + violations); + } - @Test - @DisplayName("Should report maxValue violation") - void shouldReportMaxValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), + violations); + } - @Test - @DisplayName("Should report both minValue and maxValue violations") - void shouldReportBothMinAndMaxViolations() { - // minValue > maxValue edge case — value below min triggers min violation - var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); - var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7"); - // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation - assertEquals(2, violations.size()); - } + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } - @Test - @DisplayName("Should accept decimal number values") - void shouldAcceptDecimalNumberValues() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") - void shouldReportTypeMismatchWhenBooleanSentForNumber() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), + violations); } + } - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") - @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) - void shouldAcceptValidBooleanValues(String value) { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, value); + var violations = service.validatePropertyValue(definition, value); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report type mismatch for invalid boolean value") - void shouldReportTypeMismatchForInvalidBoolean() { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes"); + var violations = service.validatePropertyValue(definition, "yes"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), + violations); + } - @Test - @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") - void shouldReportTypeMismatchWhenNumberSentForBoolean() { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @Test + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), + violations); } + } - private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { - return new PropertyDefinition(null, name, "description", type, true, rules); - } + private PropertyDefinition propertyDefinition(String name, PropertyType type, + PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 53f5b15..93330ad 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -23,242 +23,238 @@ /// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - private static final String TEMPLATE_IDENTIFIER = "web-service"; - private static final String ENTITY_IDENTIFIER = "web-api-2"; - private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; - private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; - private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - @Autowired - private MockMvc mockMvc; + private static final String TEMPLATE_IDENTIFIER = "web-service"; + private static final String ENTITY_IDENTIFIER = "web-api-2"; + private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; + private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; + private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; + @Autowired + private MockMvc mockMvc; - /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated - /// retrieval). - @Nested - @DisplayName("GET /api/v1/entities/{template-identifier} - Get Templates Paginated") - class GetEntitiesByTemplateIdentifierTests { + /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated + /// retrieval). + @Nested + @DisplayName("GET /api/v1/entities/{template-identifier} - Get Templates Paginated") + class GetEntitiesByTemplateIdentifierTests { - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_paginated_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(15)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } - - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_paginated_404_when_non_existent_template() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_paginated_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "15").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(15)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + } - @Test - @DisplayName("Should return 401 without authentication") - void getTemplates_paginated_401_without_user_token() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_paginated_404_when_non_existent_template() throws Exception { + mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + } - @Test - @DisplayName("Should return paginated entities with custom pagination") - @WithMockUser - void getEntities_paginated_200_custom() throws Exception { + @Test + @DisplayName("Should return 401 without authentication") + void getTemplates_paginated_401_without_user_token() throws Exception { + mockMvc.perform( + get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) - .andExpect(jsonPath("$.page.total_elements").value(6)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } + @Test + @DisplayName("Should return paginated entities with custom pagination") + @WithMockUser + void getEntities_paginated_200_custom() throws Exception { - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("page", "1").param("size", "5").param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); } - /// Tests for GET /api/v1/entities/{template-identifier}/identifier/{identifier} - /// endpoint (lookup by template and identifier). - @Nested - @DisplayName("GET /api/v1/entities/{template-identifier}/identifier/{identifier} - Get Entities by template identifier and entity identifier") - class GetEntitiesByTemplateAndEntityIdentifierTests { + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_invalid_pagination_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + } + } - @Test - @DisplayName("Should return entity by template identifier and identifier") - @WithMockUser - void getEntityByTemplateAndIdentifier_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) - .andExpect(jsonPath("$.template_identifier").value(TEMPLATE_IDENTIFIER)); - } + /// Tests for GET /api/v1/entities/{template-identifier}/identifier/{identifier} + /// endpoint (lookup by template and identifier). + @Nested + @DisplayName("GET /api/v1/entities/{template-identifier}/identifier/{identifier} - Get Entities by template identifier and entity identifier") + class GetEntitiesByTemplateAndEntityIdentifierTests { - @Test - @DisplayName("Should return 404 for non-existent entity") - @WithMockUser - void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return entity by template identifier and identifier") + @WithMockUser + void getEntityByTemplateAndIdentifier_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) + .andExpect(jsonPath("$.template_identifier").value(TEMPLATE_IDENTIFIER)); + } - @Test - @DisplayName("Should return 404 for non-existent entity template") - @WithMockUser - void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return 404 for non-existent entity") + @WithMockUser + void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); } - @Nested - @DisplayName("POST /api/v1/entities/{template-identifier} - Get Entities by template identifier and entity identifier") - class PostEntitiesTests { + @Test + @DisplayName("Should return 404 for non-existent entity template") + @WithMockUser + void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { + mockMvc.perform( + get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + } - @Test - @WithMockUser() - @DisplayName("Should create entity and return 201") - void postEntity_201() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } + @Nested + @DisplayName("POST /api/v1/entities/{template-identifier} - Get Entities by template identifier and entity identifier") + class PostEntitiesTests { - @Test - @WithMockUser() - @DisplayName("Should return 400 when required template properties are missing") - void postEntity_400_when_required_properties_missing() throws Exception { - var payload = """ - { - "name": "web-service-missing-required", - "identifier": "web-service-missing-required", - "properties": { - "port": "8080" - } - } - """; + @Test + @WithMockUser() + @DisplayName("Should create entity and return 201") + void postEntity_201() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .andExpect(status().isCreated()).andReturn(); + } - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); - } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; - @Test - @WithMockUser() - @DisplayName("Should return 400 when property type does not match template") - void postEntity_400_when_property_type_mismatch() throws Exception { - var payload = """ - { - "name": "web-service-invalid-type", - "identifier": "web-service-invalid-type", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "owner@example.com", - "port": "not-a-number", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "https://catalog.example.com", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; + mockMvc + .perform(MockMvcRequestBuilders + .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); - } + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; - @Test - @WithMockUser() - @DisplayName("Should return 400 when property rules are not respected") - void postEntity_400_when_property_rules_not_respected() throws Exception { - var payload = """ - { - "name": "web-service-invalid-rules", - "identifier": "web-service-invalid-rules", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "invalid-email", - "port": "80", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "invalid-url", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; + mockMvc + .perform(MockMvcRequestBuilders + .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( - org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), - org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), - org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), - org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), - org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") - ))); - } + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + mockMvc + .perform(MockMvcRequestBuilders + .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers + .containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers + .containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers + .containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers + .containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers + .containsString("Property 'port' value must be greater than or equal to 1024")))); } + } + } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index ca30cf2..38590cb 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -34,190 +34,169 @@ @DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") public class EntityGraphControllerTest extends AbstractIntegrationTest { - private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; - private static final String TEMPLATE = "web-service"; - private static final String ENTITY_A = "graph-svc-a"; - private static final String ENTITY_B = "graph-svc-b"; - private static final String ENTITY_C = "graph-svc-c"; - - @Autowired - private MockMvc mockMvc; - - @Nested - @DisplayName("Without relation filter") - class NoFilter { - - @Test - @WithMockUser - @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") - void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // All three nodes must be present - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c - .andExpect(jsonPath("$.edges", hasSize(3))); - } + private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; + private static final String TEMPLATE = "web-service"; + private static final String ENTITY_A = "graph-svc-a"; + private static final String ENTITY_B = "graph-svc-b"; + private static final String ENTITY_C = "graph-svc-c"; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Without relation filter") + class NoFilter { + + @Test + @WithMockUser + @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") + void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes must be present + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(3))); + } + } + + @Nested + @DisplayName("With 'uses' relation filter") + class UsesFilter { + + @Test + @WithMockUser + @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") + void shouldTraverseFullChainWithUsesFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("relations", "uses").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are reachable via "uses" chain: a→b→c + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Only the two "uses" edges: a-[uses]->b and b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(2))) + .andExpect(jsonPath("$.edges[*].type", containsInAnyOrder("uses", "uses"))); + } + + @Test + @WithMockUser + @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") + void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { + // This specifically verifies that the filter applies recursively: + // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "2") + .param("relations", "uses").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + .andExpect(jsonPath("$.edges", hasSize(2))); + } + } + + @Nested + @DisplayName("With 'monitors' relation filter") + class MonitorsFilter { + + @Test + @WithMockUser + @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") + void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { + // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, + // graph-svc-c must NOT appear in the result. + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("relations", "monitors").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // Only a and b — c is unreachable via "monitors" + .andExpect(jsonPath("$.nodes", hasSize(2))) + .andExpect(jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B))) + // One edge only: a-[monitors]->b + .andExpect(jsonPath("$.edges", hasSize(1))) + .andExpect(jsonPath("$.edges[0].type").value("monitors")); + } + } + + @Nested + @DisplayName("Error cases") + class ErrorCases { + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void shouldReturn404ForUnknownEntity() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); } - @Nested - @DisplayName("With 'uses' relation filter") - class UsesFilter { - - @Test - @WithMockUser - @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") - void shouldTraverseFullChainWithUsesFilter() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("relations", "uses") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // All three nodes are reachable via "uses" chain: a→b→c - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - // Only the two "uses" edges: a-[uses]->b and b-[uses]->c - .andExpect(jsonPath("$.edges", hasSize(2))) - .andExpect(jsonPath("$.edges[*].type", - containsInAnyOrder("uses", "uses"))); - } - - @Test - @WithMockUser - @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") - void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { - // This specifically verifies that the filter applies recursively: - // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "2") - .param("relations", "uses") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - .andExpect(jsonPath("$.edges", hasSize(2))); - } + @Test + @DisplayName("Should return 401 without authentication") + void shouldReturn401WithoutAuthentication() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("With 'properties' filter (include_data=true)") + class PropertyFilter { + + @Test + @WithMockUser + @DisplayName("Should include only requested property in each node's data when one property is requested") + void shouldIncludeOnlyRequestedProperty() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").param("properties", "tier").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are still returned + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Each node's data must contain "tier" … + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + // … but must NOT contain "version" + .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); } - @Nested - @DisplayName("With 'monitors' relation filter") - class MonitorsFilter { - - @Test - @WithMockUser - @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") - void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { - // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, - // graph-svc-c must NOT appear in the result. - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("relations", "monitors") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // Only a and b — c is unreachable via "monitors" - .andExpect(jsonPath("$.nodes", hasSize(2))) - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B))) - // One edge only: a-[monitors]->b - .andExpect(jsonPath("$.edges", hasSize(1))) - .andExpect(jsonPath("$.edges[0].type").value("monitors")); - } + @Test + @WithMockUser + @DisplayName("Should include multiple requested properties in each node's data") + void shouldIncludeMultipleRequestedProperties() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").param("properties", "tier") + .param("properties", "version").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); } - @Nested - @DisplayName("Error cases") - class ErrorCases { - - @Test - @WithMockUser - @DisplayName("Should return 404 when entity does not exist") - void shouldReturn404ForUnknownEntity() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } - - @Test - @DisplayName("Should return 401 without authentication") - void shouldReturn401WithoutAuthentication() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .accept(APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } + @Test + @WithMockUser + @DisplayName("Should return empty data when requested property does not exist on entity") + void shouldReturnEmptyDataForUnknownProperty() throws Exception { + mockMvc + .perform( + get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3").param("include_data", "true") + .param("properties", "non-existent-prop").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) + .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); } - @Nested - @DisplayName("With 'properties' filter (include_data=true)") - class PropertyFilter { - - @Test - @WithMockUser - @DisplayName("Should include only requested property in each node's data when one property is requested") - void shouldIncludeOnlyRequestedProperty() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .param("properties", "tier") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // All three nodes are still returned - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - // Each node's data must contain "tier" … - .andExpect(jsonPath("$.nodes[0].data.tier").exists()) - // … but must NOT contain "version" - .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); - } - - @Test - @WithMockUser - @DisplayName("Should include multiple requested properties in each node's data") - void shouldIncludeMultipleRequestedProperties() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .param("properties", "tier") - .param("properties", "version") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.nodes[0].data.tier").exists()) - .andExpect(jsonPath("$.nodes[0].data.version").exists()); - } - - @Test - @WithMockUser - @DisplayName("Should return empty data when requested property does not exist on entity") - void shouldReturnEmptyDataForUnknownProperty() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .param("properties", "non-existent-prop") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) - .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); - } - - @Test - @WithMockUser - @DisplayName("Should include all properties when no property filter is supplied") - void shouldIncludeAllPropertiesWithoutFilter() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.nodes[0].data.tier").exists()) - .andExpect(jsonPath("$.nodes[0].data.version").exists()); - } + @Test + @WithMockUser + @DisplayName("Should include all properties when no property filter is supplied") + void shouldIncludeAllPropertiesWithoutFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index c6b2a23..1e09d7d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -43,1069 +43,1018 @@ @Slf4j class EntityTemplateControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; + + @Autowired + private EntityTemplateRepositoryPort entityTemplateRepository; + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + + /// Test suite for the GET /api/v1/entity-templates endpoint, covering paginated + /// retrieval of entity templates. + /// **Test coverage includes:** + /// - Default pagination behavior and response structure + /// - Authentication requirements for accessing the endpoint + /// - Custom pagination and sorting parameters + /// - Retrieval of a specific template by identifier + /// - Filtering templates by identifier using query parameters + /// **Testing rationale:** Each test ensures the API returns the expected HTTP + /// status codes, + /// response content, and pagination metadata for proper contract verification. + @Nested + @DisplayName("GET /api/v1/entity-templates - Get Templates Paginated") + @Order(1) + class GetTemplatesPaginatedTests { + + /// Tests the GET /api/v1/entity-templates/ endpoint with default pagination + /// parameters. + /// **This test verifies that:** + /// - The endpoint returns HTTP 200 OK status + /// - Response content type is application/json + /// - All 10 templates are returned in the content array + /// - Default pagination settings are applied (page 0, size 20) + /// - Template ordering is consistent (batch-job at index 1) + /// - Pagination metadata is correctly populated + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return paginated templates with default pagination") + @WithMockUser + void getTemplates_paginated_200() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(10)) + .andExpect(jsonPath("$.content[1].identifier").value("batch-job")) + .andExpect(jsonPath("$.page.total_elements").value(10)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)); + } - @Autowired - private EntityTemplateRepositoryPort entityTemplateRepository; - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + /// Tests that accessing the /api/v1/entity-templates/ endpoint without + /// authentication returns a 401 Unauthorized status. + /// @throws Exception if an error occurs during the request + @Test + @DisplayName("Should return 401 without authentication") + void getTemplates_paginated_401_without_user_token() throws Exception { + mockMvc.perform(get(ENTITY_TEMPLATE_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + /// Tests the GET /api/v1/entity-templates/ endpoint with custom pagination + /// parameters. + /// This test verifies that: + /// - Custom pagination parameters are correctly applied (page=1, size=5, + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return paginated templates with custom pagination") + @WithMockUser + void getTemplates_paginated_200_custom() throws Exception { + + mockMvc + .perform(get("/api/v1/entity-templates").param("page", "1").param("size", "5") + .param("sort", "identifier,asc").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.content[0].identifier").value("frontend-app")) + .andExpect(jsonPath("$.page.total_elements").value(10)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); + } + + /// Tests the GET /api/v1/entity-templates/{identifier} endpoint for + /// retrieving a specific template. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 200 even with invalid pagination parameters") + @WithMockUser + void getTemplates_paginated_200_invalid_pagination() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates/frontend-app").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("frontend-app")); + } + + /// Tests the GET /api/v1/entity-templates/ endpoint with identifier query + /// parameter. + /// This test verifies that: + /// - The endpoint returns HTTP 200 OK status when identifier parameter is + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 200 with valid identifier") + @WithMockUser + void getTemplates_paginated_200_with_valid_identifier() throws Exception { + mockMvc.perform(get("/api/v1/entity-templates").param("identifier", "web-service") + .accept(APPLICATION_JSON)).andExpect(status().isOk()); + } + + } + + @Nested + @DisplayName("POST /api/v1/entity-templates - Create Template") + @Order(2) + class PostTemplateTests { + + private static final String ENTITY_TEMPLATE_JSON_TEST_PATH = "integration_test/json/entity-template/v1/"; + + /// Tests the POST /api/v1/entity-templates endpoint for successful template + /// creation. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should create template and return 201") + void postTemplate_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) + .andExpect(status().isCreated()).andReturn(); + } + + /// Tests the POST /api/v1/entity-templates endpoint without authentication. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should create template and return 401") + void postTemplate_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) + .andExpect(status().isUnauthorized()).andReturn(); + } + + /// Tests the POST /api/v1/entity-templates endpoint when the identifier field + /// is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template identifier mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when identifier is missing") + void postTemplate_400_identifier_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_missing.json", + ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when identifier field is + /// blank. + /// This test verifies that: + /// - Validation error message matches expected template identifier mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when identifier is blank") + void postTemplate_400_identifier_blank() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_blank.json", + ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field + /// already exists. + /// This test verifies that: + /// - Validation error message contains expected template name already exists + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when name already exists") + void postTemplate_409_name_already_exists() throws Exception { + MvcResult res = postConflictAndAssertContains(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_name_already_exists.json", + "The entity template name"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when the name field is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template identifier mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is missing") + void postTemplate_400_name_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_missing.json", + ValidationMessages.TEMPLATE_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field is + /// blank. + /// This test verifies that: + /// - Validation error message contains expected template name mandatory message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is blank") + void postTemplate_400_name_blank() throws Exception { + MvcResult res = postBadRequestAndAssertContains(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_blank.json", + ValidationMessages.TEMPLATE_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field is + /// too long. + /// This test verifies that: + /// - Validation error message matches expected template name too long + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is too long") + void postTemplate_400_name_too_long() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_wrong_size.json", + ValidationMessages.TEMPLATE_NAME_MAX_SIZE); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field does + /// not respect regex pattern. + /// This test verifies that: + /// - Validation error message matches expected template name pattern + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name does not respect regex pattern") + void postTemplate_400_name_invalid_pattern() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_invalid_pattern.json", + ValidationMessages.TEMPLATE_NAME_FORMAT); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when properties array is + /// empty. + /// This test verifies that: + /// - Validation error message indicates property definitions are + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property name is missing") + void postTemplate_400_property_name_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_missing.json", + ValidationMessages.PROPERTY_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property name field is + /// blank. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property name is blank") + void postTemplate_400_property_name_blank() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_blank.json", + ValidationMessages.PROPERTY_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property description + /// field is missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property description is missing") + void postTemplate_400_property_description_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_property_description_missing.json", + ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property description + /// field is blank. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property description is blank") + void postTemplate_400_property_description_blank() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_description_blank.json", + ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property type field is + /// missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property type is missing") + void postTemplate_400_property_type_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_type_missing.json", + ValidationMessages.PROPERTY_TYPE_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when trying to create a + /// template with duplicate identifier. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when identifier already exists") + void postTemplate_409_identifier_already_exists() throws Exception { + + // Then, try to create template with the existing identifier in database + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_409_identifier_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error_description").exists()).andExpect( + jsonPath("$.error_description").value(TEMPLATE_ALREADY_EXISTS + ":web-service")); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property type contains + /// an invalid enum value. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property type has invalid enum value") + void postTemplate_400_property_type_invalid_enum() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_type.json", + "Invalid value 'NOT IN ENUM' for property 'type'"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property format + /// contains an invalid enum value. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property format has invalid enum value") + void postTemplate_400_property_format_invalid_enum() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_format.json", + "Invalid value 'NOT A VALID FORMAT' for property 'format'"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint with invalid property + /// rules. + /// This test verifies that rules incompatible with property type are rejected. + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when STRING property has numeric rules") + void postTemplate_400_string_property_with_numeric_rules() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", + "Property 'property-test' of type STRING: Numeric rule max_value is not allowed for STRING properties"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint with no property + /// definitions. + /// This test verifies that: + /// - Templates can be created without any properties + /// - The endpoint returns HTTP 201 Created status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should create template without properties and return 201") + void postTemplate_201_without_properties() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_201_without_properties.json"))) + .andExpect(status().isCreated()).andReturn(); + } + + /// Tests the POST /api/v1/entity-templates endpoint with empty property array. + /// This test verifies that: + /// - Templates can be created with an empty properties array + /// - The endpoint returns HTTP 201 Created status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should create template with empty properties array and return 201") + void postTemplate_201_with_empty_properties() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_201_with_empty_properties.json"))) + .andExpect(status().isCreated()).andReturn(); + } + + /// Tests POST endpoint when duplicate property names are provided + /// (case-insensitive). + /// Verifies that PropertyNameAlreadyExistsException is thrown and returns 400 + /// Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when creating template with duplicate property names (case-insensitive)") + void postTemplate_400_duplicate_property_names() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_duplicate_property_names.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Property name 'applicationname' already exists within the template. Property names must be unique.")); + } + + /// Tests POST endpoint when duplicate relation names are provided + /// (case-insensitive). + /// Verifies that RelationNameAlreadyExistsException is thrown and returns 400 + /// Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when creating template with duplicate relation names (case-insensitive)") + void postTemplate_400_duplicate_relation_names() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_duplicate_relation_names.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Relation name 'belongsto' already exists within the template. Relation names must be unique.")); + } + + /// Tests POST endpoint when relation targets non-existent template. + /// Verifies that TargetTemplateNotFoundException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when relation targets non-existent template") + void postTemplate_400_target_template_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_target_template_not_found.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Target template with identifier 'non-existent-template' does not exist.")); + } - /// Test suite for the GET /api/v1/entity-templates endpoint, covering paginated - /// retrieval of entity templates. - /// **Test coverage includes:** - /// - Default pagination behavior and response structure - /// - Authentication requirements for accessing the endpoint - /// - Custom pagination and sorting parameters - /// - Retrieval of a specific template by identifier - /// - Filtering templates by identifier using query parameters - /// **Testing rationale:** Each test ensures the API returns the expected HTTP status codes, - /// response content, and pagination metadata for proper contract verification. - @Nested - @DisplayName("GET /api/v1/entity-templates - Get Templates Paginated") - @Order(1) - class GetTemplatesPaginatedTests { - - /// Tests the GET /api/v1/entity-templates/ endpoint with default pagination - /// parameters. - /// **This test verifies that:** - /// - The endpoint returns HTTP 200 OK status - /// - Response content type is application/json - /// - All 10 templates are returned in the content array - /// - Default pagination settings are applied (page 0, size 20) - /// - Template ordering is consistent (batch-job at index 1) - /// - Pagination metadata is correctly populated - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return paginated templates with default pagination") - @WithMockUser - void getTemplates_paginated_200() throws Exception { - - mockMvc.perform(get("/api/v1/entity-templates") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(10)) - .andExpect(jsonPath("$.content[1].identifier").value("batch-job")) - .andExpect(jsonPath("$.page.total_elements").value(10)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)); - } - - /// Tests that accessing the /api/v1/entity-templates/ endpoint without - /// authentication returns a 401 Unauthorized status. - /// @throws Exception if an error occurs during the request - @Test - @DisplayName("Should return 401 without authentication") - void getTemplates_paginated_401_without_user_token() throws Exception { - mockMvc.perform(get(ENTITY_TEMPLATE_PATH) - .accept(APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } - - /// Tests the GET /api/v1/entity-templates/ endpoint with custom pagination - /// parameters. - /// This test verifies that: - /// - Custom pagination parameters are correctly applied (page=1, size=5, - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return paginated templates with custom pagination") - @WithMockUser - void getTemplates_paginated_200_custom() throws Exception { - - mockMvc.perform(get("/api/v1/entity-templates") - .param("page", "1") - .param("size", "5") - .param("sort", "identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.content[0].identifier").value("frontend-app")) - .andExpect(jsonPath("$.page.total_elements").value(10)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } - - /// Tests the GET /api/v1/entity-templates/{identifier} endpoint for - /// retrieving a specific template. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 200 even with invalid pagination parameters") - @WithMockUser - void getTemplates_paginated_200_invalid_pagination() throws Exception { - - mockMvc.perform(get("/api/v1/entity-templates/frontend-app") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.identifier").value("frontend-app")); - } - - /// Tests the GET /api/v1/entity-templates/ endpoint with identifier query - /// parameter. - /// This test verifies that: - /// - The endpoint returns HTTP 200 OK status when identifier parameter is - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 200 with valid identifier") - @WithMockUser - void getTemplates_paginated_200_with_valid_identifier() throws Exception { - mockMvc.perform(get("/api/v1/entity-templates") - .param("identifier", "web-service") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()); - } + /// Tests POST endpoint when a relation's targetTemplateIdentifier equals the + /// template's own identifier. + /// Verifies that RelationSelfReferenceException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when a relation targets the template itself") + void postTemplate_400_relation_self_reference() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postTemplate_400_relation_target_references_itself.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Relation 'circular' cannot reference its own template 'self-ref-template'"))); + } + + } + + @Nested + @DisplayName("PUT /api/v1/entity-templates - Update Template") + @Order(3) + class PutTemplateTests { + + @Test + void putTemplate_without_user_token_401() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should update existing property rules using PUT") + void putTemplate_shouldMergePropertyRules() throws Exception { + + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplateWithoutRelationsDefinitions_201.json"))) + .andExpect(status().isCreated()); + + EntityTemplate initialTemplate = entityTemplateRepository.findByIdentifier("temp-test-99") + .orElseThrow(); + + PropertyDefinition initialProperty = initialTemplate.propertiesDefinitions().get(0); + UUID initialRulesId = initialProperty.rules().id(); + + mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/entity-templates/temp-test-99") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_updateRules_200.json"))) + .andExpect(status().isOk()); + + EntityTemplate updatedTemplate = entityTemplateRepository.findByIdentifier("temp-test-99") + .orElseThrow(); + + assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); + + PropertyDefinition updatedProperty = updatedTemplate.propertiesDefinitions().get(0); + + assertThat(updatedProperty.name()).isEqualTo("property-test"); + + PropertyRules updatedRules = updatedProperty.rules(); + assertThat(updatedRules.format()).isNull(); + assertThat(updatedRules.regex()).isEqualTo("^[a-zA-Z0-9]+$"); + assertThat(updatedRules.maxLength()).isEqualTo(255); + assertThat(updatedRules.minLength()).isNull(); + + assertThat(updatedRules.id()).isEqualTo(initialRulesId); + assertThat(updatedTemplate.relationsDefinitions()).isEmpty(); } - @Nested - @DisplayName("POST /api/v1/entity-templates - Create Template") - @Order(2) - class PostTemplateTests { - - private static final String ENTITY_TEMPLATE_JSON_TEST_PATH = "integration_test/json/entity-template/v1/"; - - /// Tests the POST /api/v1/entity-templates endpoint for successful template - /// creation. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should create template and return 201") - void postTemplate_201() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } - - /// Tests the POST /api/v1/entity-templates endpoint without authentication. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should create template and return 401") - void postTemplate_401_without_user_token() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) - .andExpect(status().isUnauthorized()) - .andReturn(); - } - - /// Tests the POST /api/v1/entity-templates endpoint when the identifier field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template identifier mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when identifier is missing") - void postTemplate_400_identifier_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_missing.json", - ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when identifier field is - /// blank. - /// This test verifies that: - /// - Validation error message matches expected template identifier mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when identifier is blank") - void postTemplate_400_identifier_blank() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_blank.json", - ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field - /// already exists. - /// This test verifies that: - /// - Validation error message contains expected template name already exists - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when name already exists") - void postTemplate_409_name_already_exists() throws Exception { - MvcResult res = postConflictAndAssertContains(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_name_already_exists.json", - "The entity template name"); - assertNotNull(res, "Test executed successfully"); - } - - - /// Tests the POST /api/v1/entity-templates endpoint when the name field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template identifier mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is missing") - void postTemplate_400_name_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_missing.json", - ValidationMessages.TEMPLATE_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field is - /// blank. - /// This test verifies that: - /// - Validation error message contains expected template name mandatory message - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is blank") - void postTemplate_400_name_blank() throws Exception { - MvcResult res = postBadRequestAndAssertContains(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_blank.json", - ValidationMessages.TEMPLATE_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field is - /// too long. - /// This test verifies that: - /// - Validation error message matches expected template name too long - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is too long") - void postTemplate_400_name_too_long() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_wrong_size.json", - ValidationMessages.TEMPLATE_NAME_MAX_SIZE); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field does - /// not respect regex pattern. - /// This test verifies that: - /// - Validation error message matches expected template name pattern - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name does not respect regex pattern") - void postTemplate_400_name_invalid_pattern() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_invalid_pattern.json", - ValidationMessages.TEMPLATE_NAME_FORMAT); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when properties array is - /// empty. - /// This test verifies that: - /// - Validation error message indicates property definitions are - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property name is missing") - void postTemplate_400_property_name_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_missing.json", - ValidationMessages.PROPERTY_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// blank. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property name is blank") - void postTemplate_400_property_name_blank() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_blank.json", - ValidationMessages.PROPERTY_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property description - /// field is missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property description is missing") - void postTemplate_400_property_description_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_description_missing.json", - ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property description - /// field is blank. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property description is blank") - void postTemplate_400_property_description_blank() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_description_blank.json", - ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property type field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property type is missing") - void postTemplate_400_property_type_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_type_missing.json", - ValidationMessages.PROPERTY_TYPE_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when trying to create a - /// template with duplicate identifier. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when identifier already exists") - void postTemplate_409_identifier_already_exists() throws Exception { - - // Then, try to create template with the existing identifier in database - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_identifier_already_exists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.error_description").exists()) - .andExpect(jsonPath("$.error_description").value(TEMPLATE_ALREADY_EXISTS + ":web-service")); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property type contains - /// an invalid enum value. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property type has invalid enum value") - void postTemplate_400_property_type_invalid_enum() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_type.json", - "Invalid value 'NOT IN ENUM' for property 'type'"); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property format - /// contains an invalid enum value. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property format has invalid enum value") - void postTemplate_400_property_format_invalid_enum() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_format.json", - "Invalid value 'NOT A VALID FORMAT' for property 'format'"); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint with invalid property rules. - /// This test verifies that rules incompatible with property type are rejected. - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when STRING property has numeric rules") - void postTemplate_400_string_property_with_numeric_rules() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", - "Property 'property-test' of type STRING: Numeric rule max_value is not allowed for STRING properties"); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint with no property definitions. - /// This test verifies that: - /// - Templates can be created without any properties - /// - The endpoint returns HTTP 201 Created status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should create template without properties and return 201") - void postTemplate_201_without_properties() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201_without_properties.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } - - /// Tests the POST /api/v1/entity-templates endpoint with empty property array. - /// This test verifies that: - /// - Templates can be created with an empty properties array - /// - The endpoint returns HTTP 201 Created status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should create template with empty properties array and return 201") - void postTemplate_201_with_empty_properties() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201_with_empty_properties.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } - - /// Tests POST endpoint when duplicate property names are provided (case-insensitive). - /// Verifies that PropertyNameAlreadyExistsException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when creating template with duplicate property names (case-insensitive)") - void postTemplate_400_duplicate_property_names() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_duplicate_property_names.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Property name 'applicationname' already exists within the template. Property names must be unique.")); - } - - /// Tests POST endpoint when duplicate relation names are provided (case-insensitive). - /// Verifies that RelationNameAlreadyExistsException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when creating template with duplicate relation names (case-insensitive)") - void postTemplate_400_duplicate_relation_names() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_duplicate_relation_names.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Relation name 'belongsto' already exists within the template. Relation names must be unique.")); - } - - /// Tests POST endpoint when relation targets non-existent template. - /// Verifies that TargetTemplateNotFoundException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when relation targets non-existent template") - void postTemplate_400_target_template_not_found() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_target_template_not_found.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Target template with identifier 'non-existent-template' does not exist.")); - } - - /// Tests POST endpoint when a relation's targetTemplateIdentifier equals the template's own identifier. - /// Verifies that RelationSelfReferenceException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when a relation targets the template itself") - void postTemplate_400_relation_self_reference() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postTemplate_400_relation_target_references_itself.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value( - containsString("Relation 'circular' cannot reference its own template 'self-ref-template'"))); - } + @Test + @WithMockUser + @DisplayName("Should update template with relations and return 200") + void putTemplate_updateRelations_200() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "identifier": "template-rel-test", + "name": "Template Rel Test", + "description": "Initial template", + "properties_definitions": [ + { + "name": "property1", + "description": "description", + "required": true, + "type": "STRING", + "rules": {} + } + ], + "relations_definitions": [ + { + "name": "owns", + "target_template_identifier": "microservice", + "required": true, + "to_many": true + } + ] + } + """)).andExpect(status().isCreated()); + + String updateJson = """ + { + "name": "Template Rel Test", + "description": "Updated template with new relation", + "properties_definitions": [ + { + "name": "property1", + "description": "Updated description", + "type": "STRING", + "required": true, + "rules": {} + } + ], + "relations_definitions": [ + { + "name": "owns", + "target_template_identifier": "microservice", + "required": false, + "to_many": false + }, + { + "name": "belongsTo", + "target_template_identifier": "database-service", + "required": true, + "to_many": false + } + ] + } + """; + + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/template-rel-test") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(updateJson)) + .andExpect(status().isOk()); + + Optional updatedTemplateOpt = entityTemplateRepository + .findByIdentifier("template-rel-test"); + assertThat(updatedTemplateOpt).isPresent(); + + EntityTemplate updatedTemplate = updatedTemplateOpt.get(); + + // Vérifier description mise à jour + assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); + + // Vérifier properties + assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); + assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) + .isEqualTo("Updated description"); + + // Vérifier relations + assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); + + Map relationsMap = updatedTemplate.relationsDefinitions().stream() + .collect(Collectors.toMap(RelationDefinition::name, r -> r)); + + assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); + assertThat(relationsMap.get("owns").required()).isFalse(); + assertThat(relationsMap.get("owns").toMany()).isFalse(); + + assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()) + .isEqualTo("database-service"); + assertThat(relationsMap.get("belongsTo").required()).isTrue(); + assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); + } + + @Test + @WithMockUser() + @DisplayName("Should update template and return 201") + void putTemplate_200() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("web-service"); + assertThat(entityTemplateUpdated).isPresent(); + assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); + assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without + /// properties. + /// This test verifies that: + /// - Templates can be updated without any properties + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template without properties and return 200") + void putTemplate_200_without_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform( + MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_without_properties.json"))) + .andExpect(status().isOk()); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty + /// properties array. + /// This test verifies that: + /// - Templates can be updated with an empty properties array + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template with empty properties array and return 200") + void putTemplate_200_with_empty_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_with_empty_properties.json"))) + .andExpect(status().isOk()); + } + @Test + @WithMockUser + void putTemplate_404_withUnknownIdentifier() throws Exception { + String identifier = "unknown-identifier"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isNotFound()).andExpect(content().string( + "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); } - @Nested - @DisplayName("PUT /api/v1/entity-templates - Update Template") - @Order(3) - class PutTemplateTests { - - @Test - void putTemplate_without_user_token_401() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - @DisplayName("Should update existing property rules using PUT") - void putTemplate_shouldMergePropertyRules() throws Exception { - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "postEntityTemplateWithoutRelationsDefinitions_201.json"))) - .andExpect(status().isCreated()); - - EntityTemplate initialTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - PropertyDefinition initialProperty = initialTemplate.propertiesDefinitions().get(0); - UUID initialRulesId = initialProperty.rules().id(); - - mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/entity-templates/temp-test-99") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "putEntityTemplate_updateRules_200.json"))) - .andExpect(status().isOk()); - - EntityTemplate updatedTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - - PropertyDefinition updatedProperty = updatedTemplate.propertiesDefinitions().get(0); - - assertThat(updatedProperty.name()).isEqualTo("property-test"); - - PropertyRules updatedRules = updatedProperty.rules(); - assertThat(updatedRules.format()).isNull(); - assertThat(updatedRules.regex()).isEqualTo("^[a-zA-Z0-9]+$"); - assertThat(updatedRules.maxLength()).isEqualTo(255); - assertThat(updatedRules.minLength()).isNull(); - - assertThat(updatedRules.id()).isEqualTo(initialRulesId); - - assertThat(updatedTemplate.relationsDefinitions()).isEmpty(); - } - - @Test - @WithMockUser - @DisplayName("Should update template with relations and return 200") - void putTemplate_updateRelations_200() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "identifier": "template-rel-test", - "name": "Template Rel Test", - "description": "Initial template", - "properties_definitions": [ - { - "name": "property1", - "description": "description", - "required": true, - "type": "STRING", - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": true, - "to_many": true - } - ] - } - """)) - .andExpect(status().isCreated()); - - String updateJson = """ - { - "name": "Template Rel Test", - "description": "Updated template with new relation", - "properties_definitions": [ - { - "name": "property1", - "description": "Updated description", - "type": "STRING", - "required": true, - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": false, - "to_many": false - }, - { - "name": "belongsTo", - "target_template_identifier": "database-service", - "required": true, - "to_many": false - } - ] - } - """; - - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/template-rel-test") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(updateJson)) - .andExpect(status().isOk()); - - Optional updatedTemplateOpt = entityTemplateRepository - .findByIdentifier("template-rel-test"); - assertThat(updatedTemplateOpt).isPresent(); - - EntityTemplate updatedTemplate = updatedTemplateOpt.get(); - - // Vérifier description mise à jour - assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); - - // Vérifier properties - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) - .isEqualTo("Updated description"); - - // Vérifier relations - assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); - - Map relationsMap = updatedTemplate.relationsDefinitions() - .stream() - .collect(Collectors.toMap(RelationDefinition::name, r -> r)); - - assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); - assertThat(relationsMap.get("owns").required()).isFalse(); - assertThat(relationsMap.get("owns").toMany()).isFalse(); - - assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()).isEqualTo("database-service"); - assertThat(relationsMap.get("belongsTo").required()).isTrue(); - assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); - } - - @Test - @WithMockUser() - @DisplayName("Should update template and return 201") - void putTemplate_200() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("web-service"); - assertThat(entityTemplateUpdated).isPresent(); - assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); - assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without properties. - /// This test verifies that: - /// - Templates can be updated without any properties - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template without properties and return 200") - void putTemplate_200_without_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_without_properties.json"))) - .andExpect(status().isOk()); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty properties array. - /// This test verifies that: - /// - Templates can be updated with an empty properties array - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template with empty properties array and return 200") - void putTemplate_200_with_empty_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_with_empty_properties.json"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void putTemplate_404_withUnknownIdentifier() throws Exception { - String identifier = "unknown-identifier"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isNotFound()) - .andExpect(content().string( - "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyTypeIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); - } - - @Test - @WithMockUser() - void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { - String identifier = "web-service"; - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("microservice"); - assertThat(entityTemplateUpdated).isPresent(); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string( - "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template name mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is missing") - void putTemplate_400_name_missing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// blank. - /// This test verifies that: - /// - Validation error message contains expected template name mandatory message - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is blank") - void putTemplate_400_name_blank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field - /// already exists. - /// This test verifies that: - /// - Validation error message contains expected template name already exists - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when name already exists") - void putTemplate_409_name_already_exists() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string("{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// too long. - /// This test verifies that: - /// - Validation error message matches expected template name too long - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is too long") - void putTemplate_400_name_too_long() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field does - /// not respect regex pattern. - /// This test verifies that: - /// - Validation error message matches expected template name pattern - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name does not respect regex pattern") - void putTemplate_400_name_invalid_pattern() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); - } - - /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects - /// requests with an identifier field in the request body. - /// **This test verifies that:** - /// - The endpoint returns HTTP 400 Bad Request when identifier is in body - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should reject PUT request with identifier in body and return 400") - void putTemplate_400_identifier_in_body() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_400_identifier_in_body.json"))) - .andExpect(status().isBadRequest()); - } - - /// Tests PUT endpoint when attempting to change property type on existing property. - /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing property type") - void putTemplate_400_type_change() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_type_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); - } - - /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an existing relation. - /// Verifies that RelationTargetTemplateChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") - void putTemplate_400_target_template_identifier_change() throws Exception { - String identifier = "microservice"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_target_template_identifier_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value( - containsString("Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); - } + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + @Test + @WithMockUser() + void putTemplate_400_propertyTypeIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); } - @Nested - @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") - @Order(4) - class DeleteTemplateTests { - - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful - /// template deletion. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should delete template and return 204") - void deleteTemplate_204() throws Exception { - // Use an existing template ID from test data - String templateId = "monitoring-service"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - assertNotNull(templateId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does - /// not exist. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should return 404 when template not found") - void deleteTemplate_404_not_found() throws Exception { - // Use a non-existent template ID - String nonExistentId = "non-existing-identifier"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.error").value("NOT_FOUND")) - .andExpect(jsonPath("$.error_description").exists()); - - assertNotNull(nonExistentId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication is missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 401 when deleting without user token") - void deleteTemplate_401_without_user_token() throws Exception { - String templateId = "123e4567-e89b-12d3-a456-426614174001"; - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isUnauthorized()); - - } + @Test + @WithMockUser() + void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { + String identifier = "web-service"; + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("microservice"); + assertThat(entityTemplateUpdated).isPresent(); + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name + /// field is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template name mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is missing") + void putTemplate_400_name_missing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// blank. + /// This test verifies that: + /// - Validation error message contains expected template name mandatory message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is blank") + void putTemplate_400_name_blank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// already exists. + /// This test verifies that: + /// - Validation error message contains expected template name already exists + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when name already exists") + void putTemplate_409_name_already_exists() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// too long. + /// This test verifies that: + /// - Validation error message matches expected template name too long + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is too long") + void putTemplate_400_name_too_long() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// does + /// not respect regex pattern. + /// This test verifies that: + /// - Validation error message matches expected template name pattern + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name does not respect regex pattern") + void putTemplate_400_name_invalid_pattern() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); + } + + /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects + /// requests with an identifier field in the request body. + /// **This test verifies that:** + /// - The endpoint returns HTTP 400 Bad Request when identifier is in body + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should reject PUT request with identifier in body and return 400") + void putTemplate_400_identifier_in_body() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_400_identifier_in_body.json"))) + .andExpect(status().isBadRequest()); + } + + /// Tests PUT endpoint when attempting to change property type on existing + /// property. + /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing property type") + void putTemplate_400_type_change() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_type_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); + } + + /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an + /// existing relation. + /// Verifies that RelationTargetTemplateChangeException is thrown and returns + /// 400 Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") + void putTemplate_400_target_template_identifier_change() throws Exception { + String identifier = "microservice"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_target_template_identifier_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); + } + + } + + @Nested + @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") + @Order(4) + class DeleteTemplateTests { + + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful + /// template deletion. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should delete template and return 204") + void deleteTemplate_204() throws Exception { + // Use an existing template ID from test data + String templateId = "monitoring-service"; + + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + assertNotNull(templateId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does + /// not exist. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should return 404 when template not found") + void deleteTemplate_404_not_found() throws Exception { + // Use a non-existent template ID + String nonExistentId = "non-existing-identifier"; + + mockMvc + .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + + assertNotNull(nonExistentId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication + /// is missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 401 when deleting without user token") + void deleteTemplate_401_without_user_token() throws Exception { + String templateId = "123e4567-e89b-12d3-a456-426614174001"; + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java index a867c9c..78ad565 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java @@ -14,11 +14,10 @@ /// without authentication, ensuring system monitoring capabilities work correctly. class HealthControllerTest extends AbstractIntegrationTest { - @Test - void getHealthWithoutAuth() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.get("/actuator/health").accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andReturn(); - } + @Test + void getHealthWithoutAuth() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/actuator/health").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andReturn(); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 4a02053..dc7e402 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -10,6 +10,9 @@ import java.util.Set; import java.util.stream.Stream; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -33,9 +36,6 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; - /// Comprehensive unit tests for [ApiExceptionHandler]. /// /// Tests all exception handler methods and utility functions to ensure proper @@ -43,457 +43,479 @@ @DisplayName("ApiExceptionHandler Tests") class ApiExceptionHandlerTest { - private ApiExceptionHandler exceptionHandler; + private ApiExceptionHandler exceptionHandler; + + @BeforeEach + void setUp() throws Exception { + // Use reflection to create instance since constructor is private + Constructor constructor = ApiExceptionHandler.class + .getDeclaredConstructor(); + constructor.setAccessible(true); + exceptionHandler = constructor.newInstance(); + } + + @Nested + @DisplayName("Domain Exception Handling") + class DomainExceptionTests { + + /// Tests the handling of [EntityTemplateNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the correct error status and description + /// - Original exception message is preserved in the response + @Test + @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") + void shouldHandleEntityTemplateNotFoundException() { + // Given + String errorMessage = "Template with ID 'test-id' not found"; + EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); + + // When + ResponseEntity response = exceptionHandler + .handleTemplateNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(errorMessage, body.getErrorDescription()); + } + + /// Tests the handling of [EntityTemplateAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and formatted description + /// - Exception message is properly formatted with validation constants + @Test + @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateAlreadyExistsException() { + // Given + String identifier = "duplicate-id"; + EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException( + identifier); + String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(expectedMessage, body.getErrorDescription()); + } - @BeforeEach - void setUp() throws Exception { - // Use reflection to create instance since constructor is private - Constructor constructor = ApiExceptionHandler.class.getDeclaredConstructor(); - constructor.setAccessible(true); - exceptionHandler = constructor.newInstance(); + /// Tests the handling of [EntityAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", + "api-gateway"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Domain Exception Handling") - class DomainExceptionTests { - - /// Tests the handling of [EntityTemplateNotFoundException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the correct error status and description - /// - Original exception message is preserved in the response - @Test - @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") - void shouldHandleEntityTemplateNotFoundException() { - // Given - String errorMessage = "Template with ID 'test-id' not found"; - EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); - - // When - ResponseEntity response = exceptionHandler.handleTemplateNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(errorMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateAlreadyExistsException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and formatted description - /// - Exception message is properly formatted with validation constants - @Test - @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateAlreadyExistsException() { - // Given - String identifier = "duplicate-id"; - EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException(identifier); - String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; - - // When - ResponseEntity response = exceptionHandler.handleEntityTemplateAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(expectedMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the original domain exception message - @Test - @DisplayName("Should handle EntityAlreadyExistsException with 409 status") - void shouldHandleEntityAlreadyExistsException() { - // Given - EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); - - // When - ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle EntityValidationException with 400 status") - void shouldHandleEntityValidationException() { - EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); - - ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNameAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and description - @Test - @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateNameAlreadyExistsException() { - // Given - String name = "Duplicate Name"; - EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); - - // When - ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the entity-specific context message - @Test - @DisplayName("Should handle EntityNotFoundException with 404 status") - void shouldHandleEntityNotFoundException() { - // Given - EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); - - // When - ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException( + java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler + .handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation("Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); - } - - /// Tests the handling of [ConstraintViolationException] with multiple validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation("Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation("Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } - - /// Tests the handling of [MethodArgumentNotValidException] with field validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); - - // When - ResponseEntity response = exceptionHandler.handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } - - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } - - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException( + name); + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Validation Exception Handling") + class ValidationExceptionTests { + + /// Tests the handling of [ConstraintViolationException] with a single + /// validation violation. + /// + /// **This test verifies that:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); } - @Nested - @DisplayName("HTTP Message Exception Handling") - class HttpMessageExceptionTests { - - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_FORMAT' for property 'format'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ), - Arguments.of( - "Cannot deserialize value of type `com.example.SomeType`: some other error", - "Invalid type: expected SomeType" - ), - Arguments.of( - "Something completely unexpected happened", - "Invalid request body format" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", - "Invalid value for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - - /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. - /// - /// **This test verifies that:** - /// - HttpMessageNotReadableException with null message is properly handled - /// - HTTP 400 Bad Request status is returned - /// - Default error message is provided when original message is null - /// - Graceful handling of edge case scenarios - @Test - @DisplayName("Should handle HttpMessageNotReadableException with null message") - void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(null); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Invalid request body format", body.getErrorDescription()); - } - - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. - /// - /// **This test verifies that different types of HttpMessageNotReadableException are properly - /// parsed and converted to user-friendly error messages:** - /// - Missing request body errors → "Request body is required" - /// - JSON parse errors → "Invalid JSON format in request body" - /// - PropertyType enum deserialization errors → Specific property and value information - /// - Unknown enum deserialization errors → Generic enum error message - /// - /// **Each test case validates that:** - /// - HTTP 400 Bad Request status is returned - /// - Original complex error message is parsed and simplified - /// - User-friendly error description is provided - /// - Error response structure is consistent - /// - /// @param originalMessage the original exception message to be processed - /// @param expectedErrorDescription the expected user-friendly error description - @ParameterizedTest - @MethodSource("httpMessageNotReadableExceptionTestData") - @DisplayName("Should handle HttpMessageNotReadableException with various error types") - void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, String expectedErrorDescription) { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(originalMessage); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(expectedErrorDescription, body.getErrorDescription()); - } + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); } - @Nested - @DisplayName("Generic Exception Handling") - class GenericExceptionTests { - - /// Tests the handling of generic Exception as a fallback mechanism. - /// - /// **This test verifies that:** - /// - Unexpected exceptions are caught by the generic handler - /// - HTTP 500 Internal Server Error status is returned - /// - Generic error message is provided to avoid exposing internal details - /// - Exception is properly logged for debugging purposes - @Test - @DisplayName("Should handle generic Exception with 500 status") - void shouldHandleGenericException() { - // Given - Exception exception = new RuntimeException("Unexpected error"); - - // When - ResponseEntity response = exceptionHandler.handleGenericException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); - assertEquals("An unexpected error occurred. Please try again later.", body.getErrorDescription()); - } + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } + } + + @Nested + @DisplayName("HTTP Message Exception Handling") + class HttpMessageExceptionTests { + + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of("Required request body is missing: public ResponseEntity", + "Request body is required"), + Arguments.of("JSON parse error: Unexpected character", + "Invalid JSON format in request body"), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'"), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'"), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body"), + Arguments.of("Cannot deserialize value of type `com.example.SomeType`: some other error", + "Invalid type: expected SomeType"), + Arguments.of("Something completely unexpected happened", "Invalid request body format"), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'"), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body")); + } + + /// Tests the handling of [HttpMessageNotReadableException] when exception + /// message is null. + /// + /// **This test verifies that:** + /// - HttpMessageNotReadableException with null message is properly handled + /// - HTTP 400 Bad Request status is returned + /// - Default error message is provided when original message is null + /// - Graceful handling of edge case scenarios + @Test + @DisplayName("Should handle HttpMessageNotReadableException with null message") + void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(null); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Invalid request body format", body.getErrorDescription()); + } + + /// Parameterized test for handling [HttpMessageNotReadableException] with + /// various error scenarios. + /// + /// **This test verifies that different types of HttpMessageNotReadableException + /// are properly + /// parsed and converted to user-friendly error messages:** + /// - Missing request body errors → "Request body is required" + /// - JSON parse errors → "Invalid JSON format in request body" + /// - PropertyType enum deserialization errors → Specific property and value + /// information + /// - Unknown enum deserialization errors → Generic enum error message + /// + /// **Each test case validates that:** + /// - HTTP 400 Bad Request status is returned + /// - Original complex error message is parsed and simplified + /// - User-friendly error description is provided + /// - Error response structure is consistent + /// + /// @param originalMessage the original exception message to be processed + /// @param expectedErrorDescription the expected user-friendly error description + @ParameterizedTest + @MethodSource("httpMessageNotReadableExceptionTestData") + @DisplayName("Should handle HttpMessageNotReadableException with various error types") + void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, + String expectedErrorDescription) { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(originalMessage); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(expectedErrorDescription, body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Generic Exception Handling") + class GenericExceptionTests { + + /// Tests the handling of generic Exception as a fallback mechanism. + /// + /// **This test verifies that:** + /// - Unexpected exceptions are caught by the generic handler + /// - HTTP 500 Internal Server Error status is returned + /// - Generic error message is provided to avoid exposing internal details + /// - Exception is properly logged for debugging purposes + @Test + @DisplayName("Should handle generic Exception with 500 status") + void shouldHandleGenericException() { + // Given + Exception exception = new RuntimeException("Unexpected error"); + + // When + ResponseEntity response = exceptionHandler.handleGenericException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); + assertEquals("An unexpected error occurred. Please try again later.", + body.getErrorDescription()); + } + } + + @Nested + @DisplayName("ErrorResponse Class Tests") + class ErrorResponseTests { + + /// Tests the creation of [ErrorResponse] using the all-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated with HttpStatus and description + /// - All fields are properly initialized with provided values + /// - Getter methods return the expected values + /// - Object is successfully created and accessible + @Test + @DisplayName("Should create ErrorResponse with all args constructor") + void shouldCreateErrorResponseWithAllArgsConstructor() { + // Given + HttpStatus status = HttpStatus.BAD_REQUEST; + String description = "Test error message"; + + // When + ErrorResponse errorResponse = new ErrorResponse(status.name(), description); + + // Then + assertNotNull(errorResponse); + assertEquals(status.name(), errorResponse.getError()); + assertEquals(description, errorResponse.getErrorDescription()); } - @Nested - @DisplayName("ErrorResponse Class Tests") - class ErrorResponseTests { - - /// Tests the creation of [ErrorResponse] using the all-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated with HttpStatus and description - /// - All fields are properly initialized with provided values - /// - Getter methods return the expected values - /// - Object is successfully created and accessible - @Test - @DisplayName("Should create ErrorResponse with all args constructor") - void shouldCreateErrorResponseWithAllArgsConstructor() { - // Given - HttpStatus status = HttpStatus.BAD_REQUEST; - String description = "Test error message"; - - // When - ErrorResponse errorResponse = new ErrorResponse(status.name(), description); - - // Then - assertNotNull(errorResponse); - assertEquals(status.name(), errorResponse.getError()); - assertEquals(description, errorResponse.getErrorDescription()); - } - - /// Tests the creation of [ErrorResponse] using the no-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated without parameters - /// - Object is successfully created with default/null field values - /// - Constructor works with `@NoArgsConstructor(force = true)` annotation - /// - Provides flexibility for frameworks requiring default constructors - @Test - @DisplayName("Should create ErrorResponse with no args constructor") - void shouldCreateErrorResponseWithNoArgsConstructor() { - ErrorResponse errorResponse = new ErrorResponse(); - assertNotNull(errorResponse); - } + /// Tests the creation of [ErrorResponse] using the no-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated without parameters + /// - Object is successfully created with default/null field values + /// - Constructor works with `@NoArgsConstructor(force = true)` annotation + /// - Provides flexibility for frameworks requiring default constructors + @Test + @DisplayName("Should create ErrorResponse with no args constructor") + void shouldCreateErrorResponseWithNoArgsConstructor() { + ErrorResponse errorResponse = new ErrorResponse(); + assertNotNull(errorResponse); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java index 0771e99..0ce3e06 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java @@ -30,609 +30,465 @@ @DisplayName("EntityTemplateMapper Tests") class EntityTemplateMapperTest { - private EntityTemplateMapper mapper; + private EntityTemplateMapper mapper; + + @BeforeEach + void setUp() { + mapper = new EntityTemplateMapper(); + } + + @Nested + @DisplayName("EntityTemplate Mapping Tests") + class EntityTemplateMappingTests { + + @Test + @DisplayName("Should map EntityTemplateCreateDtoIn to EntityTemplate") + void shouldMapDtoInToEntity() { + // Given + var propertyRules = PropertyRulesDtoIn.builder().format(PropertyFormat.URL) + .enumValues(new String[]{}).regex("").maxLength(200).minLength(1).maxValue(0).minValue(0) + .build(); + + var propertyDefinition = PropertyDefinitionDtoIn.builder().name("applicationName") + .description("Name of the application").type(PropertyType.STRING).required(true) + .rules(propertyRules).build(); + + var relationDefinition = RelationDefinitionDtoIn.builder().name("dependencies") + .targetTemplateIdentifier("service").required(false).toMany(true).build(); + + var commonFields = EntityTemplateDtoInCommonFields.builder().description("A service template") + .propertiesDefinitions(List.of(propertyDefinition)) + .relationsDefinitions(List.of(relationDefinition)).build(); + + var dto = EntityTemplateCreateDtoIn.builder().identifier("service-template") + .commonFields(commonFields).build(); + + // When + EntityTemplate result = mapper.fromDtoToEntityTemplate(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.identifier()).isEqualTo("service-template"); + assertThat(result.description()).isEqualTo("A service template"); + assertThat(result.propertiesDefinitions()).hasSize(1); + assertThat(result.relationsDefinitions()).hasSize(1); + + // Check property definition + PropertyDefinition mappedProperty = result.propertiesDefinitions().get(0); + assertThat(mappedProperty.name()).isEqualTo("applicationName"); + assertThat(mappedProperty.description()).isEqualTo("Name of the application"); + assertThat(mappedProperty.type()).isEqualTo(PropertyType.STRING); + assertThat(mappedProperty.required()).isTrue(); + + // Check relation definition + RelationDefinition mappedRelation = result.relationsDefinitions().get(0); + assertThat(mappedRelation.name()).isEqualTo("dependencies"); + assertThat(mappedRelation.targetTemplateIdentifier()).isEqualTo("service"); + assertThat(mappedRelation.required()).isFalse(); + assertThat(mappedRelation.toMany()).isTrue(); + } + + @Test + @DisplayName("Should handle null EntityTemplateCreateDtoIn") + void shouldHandleNullDtoIn() { + // When + EntityTemplate result = mapper.fromDtoToEntityTemplate((EntityTemplateCreateDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map EntityTemplate to EntityTemplateDtoOut") + void shouldMapEntityToDtoOut() { + // Given + var propertyRules = new PropertyRules(UUID.randomUUID(), PropertyFormat.URL, List.of(), "", + 200, 1, 0, 0); + + var propertyDefinition = new PropertyDefinition(UUID.randomUUID(), "applicationName", + "Name of the application", PropertyType.STRING, true, propertyRules); + + var relationDefinition = new RelationDefinition(UUID.randomUUID(), "dependencies", "service", + false, true); + + var entity = new EntityTemplate(UUID.randomUUID(), "service-template", "Service Template", + "A service template", List.of(propertyDefinition), List.of(relationDefinition)); + + // When + EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getIdentifier()).isEqualTo("service-template"); + assertThat(result.getDescription()).isEqualTo("A service template"); + assertThat(result.getPropertiesDefinitions()).hasSize(1); + assertThat(result.getRelationsDefinitions()).hasSize(1); + + // Check property definition + PropertyDefinitionDtoOut mappedProperty = result.getPropertiesDefinitions().get(0); + assertThat(mappedProperty.getName()).isEqualTo("applicationName"); + assertThat(mappedProperty.getDescription()).isEqualTo("Name of the application"); + assertThat(mappedProperty.getType()).isEqualTo(PropertyType.STRING); + assertThat(mappedProperty.isRequired()).isTrue(); + + // Check relation definition + RelationDefinitionDtoOut mappedRelation = result.getRelationsDefinitions().get(0); + assertThat(mappedRelation.getName()).isEqualTo("dependencies"); + assertThat(mappedRelation.getTargetTemplateIdentifier()).isEqualTo("service"); + assertThat(mappedRelation.isRequired()).isFalse(); + assertThat(mappedRelation.isToMany()).isTrue(); + } + + @Test + @DisplayName("Should handle null EntityTemplate") + void shouldHandleNullEntity() { + // When + EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto((EntityTemplate) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map list of EntityTemplate to list of EntityTemplateDtoOut") + void shouldMapEntityListToDtoOutList() { + // Given + var entity1 = new EntityTemplate(UUID.randomUUID(), "template1", "Template 1", + "Description 1", List.of(), List.of()); + + var entity2 = new EntityTemplate(UUID.randomUUID(), "template2", "Template 2", + "Description 2", List.of(), List.of()); + + List entities = List.of(entity1, entity2); + + // When + List result = mapper.fromEntityTemplatesToDtos(entities); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getIdentifier()).isEqualTo("template1"); + assertThat(result.get(1).getIdentifier()).isEqualTo("template2"); + } + + @Test + @DisplayName("Should handle null list of EntityTemplate") + void shouldHandleNullEntityList() { + // When + List result = mapper.fromEntityTemplatesToDtos(null); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("PropertyDefinition Mapping Tests") + class PropertyDefinitionMappingTests { + + @Test + @DisplayName("Should map PropertyDefinitionDtoIn to PropertyDefinition") + void shouldMapPropertyDtoInToEntity() { + // Given + var rules = PropertyRulesDtoIn.builder().format(PropertyFormat.EMAIL) + .enumValues(new String[]{"value1", "value2"}).regex(".*@.*").maxLength(100).minLength(5) + .maxValue(10).minValue(1).build(); + + var dto = PropertyDefinitionDtoIn.builder().name("email").description("User email address") + .type(PropertyType.STRING).required(true).rules(rules).build(); + + // When + PropertyDefinition result = mapper.toToPropertyDefinition(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("email"); + assertThat(result.description()).isEqualTo("User email address"); + assertThat(result.type()).isEqualTo(PropertyType.STRING); + assertThat(result.required()).isTrue(); + assertThat(result.rules()).isNotNull(); + assertThat(result.rules().format()).isEqualTo(PropertyFormat.EMAIL); + } + + @Test + @DisplayName("Should handle null PropertyDefinitionDtoIn") + void shouldHandleNullPropertyDtoIn() { + // When + PropertyDefinition result = mapper.toToPropertyDefinition((PropertyDefinitionDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map PropertyDefinition to PropertyDefinitionDtoOut") + void shouldMapPropertyEntityToDtoOut() { + // Given + var rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, + List.of("value1", "value2"), ".*@.*", 100, 5, 10, 1); + + var entity = new PropertyDefinition(UUID.randomUUID(), "email", "User email address", + PropertyType.STRING, true, rules); + + // When + PropertyDefinitionDtoOut result = mapper.toDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("email"); + assertThat(result.getDescription()).isEqualTo("User email address"); + assertThat(result.getType()).isEqualTo(PropertyType.STRING); + assertThat(result.isRequired()).isTrue(); + assertThat(result.getRules()).isNotNull(); + } + + @Test + @DisplayName("Should handle null PropertyDefinition") + void shouldHandleNullPropertyEntity() { + // When + PropertyDefinitionDtoOut result = mapper.toDto((PropertyDefinition) null); + + // Then + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("PropertyRules Mapping Tests") + class PropertyRulesMappingTests { + + @Test + @DisplayName("Should map PropertyRulesDtoIn to PropertyRules") + void shouldMapRulesDtoInToEntity() { + // Given + var dto = PropertyRulesDtoIn.builder().format(PropertyFormat.URL) + .enumValues(new String[]{"HTTP", "HTTPS"}).regex("^https?://.*").maxLength(500) + .minLength(10).maxValue(100).minValue(1).build(); + + // When + PropertyRules result = mapper.toPropertyRules(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.format()).isEqualTo(PropertyFormat.URL); + assertThat(result.enumValues()).containsExactly("HTTP", "HTTPS"); + assertThat(result.regex()).isEqualTo("^https?://.*"); + assertThat(result.maxLength()).isEqualTo(500); + assertThat(result.minLength()).isEqualTo(10); + assertThat(result.maxValue()).isEqualTo(100); + assertThat(result.minValue()).isEqualTo(1); + } + + @Test + @DisplayName("Should normalize enum_values to uppercase") + void shouldNormalizeEnumValuesToUppercase() { + // Given + var dto = PropertyRulesDtoIn.builder() + .enumValues(new String[]{"EMAil", "postal_code", "ACTIVE"}).build(); + + // When + PropertyRules result = mapper.toPropertyRules(dto); + + // Then + assertThat(result.enumValues()).containsExactly("EMAIL", "POSTAL_CODE", "ACTIVE"); + } - @BeforeEach - void setUp() { - mapper = new EntityTemplateMapper(); + @Test + @DisplayName("Should handle null PropertyRulesDtoIn") + void shouldHandleNullRulesDtoIn() { + // When + PropertyRules result = mapper.toPropertyRules((PropertyRulesDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map PropertyRules to PropertyRulesDtoOut") + void shouldMapRulesEntityToDtoOut() { + // Given + var entity = new PropertyRules(UUID.randomUUID(), PropertyFormat.URL, + List.of("http", "https"), "^https?://.*", 500, 10, 100, 1); + + // When + PropertyRulesDtoOut result = mapper.toDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getFormat()).isEqualTo(PropertyFormat.URL); + assertThat(result.getEnumValues()).containsExactly("http", "https"); + assertThat(result.getRegex()).isEqualTo("^https?://.*"); + assertThat(result.getMaxLength()).isEqualTo(500); + assertThat(result.getMinLength()).isEqualTo(10); + assertThat(result.getMaxValue()).isEqualTo(100); + assertThat(result.getMinValue()).isEqualTo(1); + } + + @Test + @DisplayName("Should handle null PropertyRules") + void shouldHandleNullRulesEntity() { + // When + PropertyRulesDtoOut result = mapper.toDto((PropertyRules) null); + + // Then + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("RelationDefinition Mapping Tests") + class RelationDefinitionMappingTests { + + @Test + @DisplayName("Should map RelationDefinitionDtoIn to RelationDefinition") + void shouldMapRelationDtoInToEntity() { + // Given + var dto = RelationDefinitionDtoIn.builder().name("parentService") + .targetTemplateIdentifier("service").required(true).toMany(false).build(); + + // When + RelationDefinition result = mapper.toRelationDefinition(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("parentService"); + assertThat(result.targetTemplateIdentifier()).isEqualTo("service"); + assertThat(result.required()).isTrue(); + assertThat(result.toMany()).isFalse(); + } + + @Test + @DisplayName("Should handle null RelationDefinitionDtoIn") + void shouldHandleNullRelationDtoIn() { + // When + RelationDefinition result = mapper.toRelationDefinition((RelationDefinitionDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map RelationDefinition to RelationDefinitionDtoOut") + void shouldMapRelationEntityToDtoOut() { + // Given + var entity = new RelationDefinition(UUID.randomUUID(), "childServices", "service", false, + true); + + // When + RelationDefinitionDtoOut result = mapper.toDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("childServices"); + assertThat(result.getTargetTemplateIdentifier()).isEqualTo("service"); + assertThat(result.isRequired()).isFalse(); + assertThat(result.isToMany()).isTrue(); } - @Nested - @DisplayName("EntityTemplate Mapping Tests") - class EntityTemplateMappingTests { - - @Test - @DisplayName("Should map EntityTemplateCreateDtoIn to EntityTemplate") - void shouldMapDtoInToEntity() { - // Given - var propertyRules = PropertyRulesDtoIn.builder() - .format(PropertyFormat.URL) - .enumValues(new String[]{}) - .regex("") - .maxLength(200) - .minLength(1) - .maxValue(0) - .minValue(0) - .build(); - - var propertyDefinition = PropertyDefinitionDtoIn.builder() - .name("applicationName") - .description("Name of the application") - .type(PropertyType.STRING) - .required(true) - .rules(propertyRules) - .build(); - - var relationDefinition = RelationDefinitionDtoIn.builder() - .name("dependencies") - .targetTemplateIdentifier("service") - .required(false) - .toMany(true) - .build(); - - var commonFields = EntityTemplateDtoInCommonFields.builder() - .description("A service template") - .propertiesDefinitions(List.of(propertyDefinition)) - .relationsDefinitions(List.of(relationDefinition)) - .build(); - - var dto = EntityTemplateCreateDtoIn.builder() - .identifier("service-template") - .commonFields(commonFields) - .build(); - - // When - EntityTemplate result = mapper.fromDtoToEntityTemplate(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.identifier()).isEqualTo("service-template"); - assertThat(result.description()).isEqualTo("A service template"); - assertThat(result.propertiesDefinitions()).hasSize(1); - assertThat(result.relationsDefinitions()).hasSize(1); - - // Check property definition - PropertyDefinition mappedProperty = result.propertiesDefinitions().get(0); - assertThat(mappedProperty.name()).isEqualTo("applicationName"); - assertThat(mappedProperty.description()).isEqualTo("Name of the application"); - assertThat(mappedProperty.type()).isEqualTo(PropertyType.STRING); - assertThat(mappedProperty.required()).isTrue(); - - // Check relation definition - RelationDefinition mappedRelation = result.relationsDefinitions().get(0); - assertThat(mappedRelation.name()).isEqualTo("dependencies"); - assertThat(mappedRelation.targetTemplateIdentifier()).isEqualTo("service"); - assertThat(mappedRelation.required()).isFalse(); - assertThat(mappedRelation.toMany()).isTrue(); - } - - @Test - @DisplayName("Should handle null EntityTemplateCreateDtoIn") - void shouldHandleNullDtoIn() { - // When - EntityTemplate result = mapper.fromDtoToEntityTemplate((EntityTemplateCreateDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map EntityTemplate to EntityTemplateDtoOut") - void shouldMapEntityToDtoOut() { - // Given - var propertyRules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.URL, - List.of(), - "", - 200, - 1, - 0, - 0 - ); - - var propertyDefinition = new PropertyDefinition( - UUID.randomUUID(), - "applicationName", - "Name of the application", - PropertyType.STRING, - true, - propertyRules - ); - - var relationDefinition = new RelationDefinition( - UUID.randomUUID(), - "dependencies", - "service", - false, - true - ); - - var entity = new EntityTemplate( - UUID.randomUUID(), - "service-template", - "Service Template", - "A service template", - List.of(propertyDefinition), - List.of(relationDefinition) - ); - - // When - EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getIdentifier()).isEqualTo("service-template"); - assertThat(result.getDescription()).isEqualTo("A service template"); - assertThat(result.getPropertiesDefinitions()).hasSize(1); - assertThat(result.getRelationsDefinitions()).hasSize(1); - - // Check property definition - PropertyDefinitionDtoOut mappedProperty = result.getPropertiesDefinitions().get(0); - assertThat(mappedProperty.getName()).isEqualTo("applicationName"); - assertThat(mappedProperty.getDescription()).isEqualTo("Name of the application"); - assertThat(mappedProperty.getType()).isEqualTo(PropertyType.STRING); - assertThat(mappedProperty.isRequired()).isTrue(); - - // Check relation definition - RelationDefinitionDtoOut mappedRelation = result.getRelationsDefinitions().get(0); - assertThat(mappedRelation.getName()).isEqualTo("dependencies"); - assertThat(mappedRelation.getTargetTemplateIdentifier()).isEqualTo("service"); - assertThat(mappedRelation.isRequired()).isFalse(); - assertThat(mappedRelation.isToMany()).isTrue(); - } - - @Test - @DisplayName("Should handle null EntityTemplate") - void shouldHandleNullEntity() { - // When - EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto((EntityTemplate) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map list of EntityTemplate to list of EntityTemplateDtoOut") - void shouldMapEntityListToDtoOutList() { - // Given - var entity1 = new EntityTemplate( - UUID.randomUUID(), - "template1", - "Template 1", - "Description 1", - List.of(), - List.of() - ); - - var entity2 = new EntityTemplate( - UUID.randomUUID(), - "template2", - "Template 2", - "Description 2", - List.of(), - List.of() - ); - - List entities = List.of(entity1, entity2); - - // When - List result = mapper.fromEntityTemplatesToDtos(entities); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getIdentifier()).isEqualTo("template1"); - assertThat(result.get(1).getIdentifier()).isEqualTo("template2"); - } - - @Test - @DisplayName("Should handle null list of EntityTemplate") - void shouldHandleNullEntityList() { - // When - List result = mapper.fromEntityTemplatesToDtos(null); - - // Then - assertThat(result).isEmpty(); - } + @Test + @DisplayName("Should handle null RelationDefinition") + void shouldHandleNullRelationEntity() { + // When + RelationDefinitionDtoOut result = mapper.toDto((RelationDefinition) null); + + // Then + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("List Mapping Tests") + class ListMappingTests { + + @Test + @DisplayName("Should map list of PropertyDefinitionDtoIn to list of PropertyDefinition") + void shouldMapPropertyDtoInListToEntityList() { + // Given + var dto1 = PropertyDefinitionDtoIn.builder().name("prop1").description("Property 1") + .type(PropertyType.STRING).required(true).build(); + + var dto2 = PropertyDefinitionDtoIn.builder().name("prop2").description("Property 2") + .type(PropertyType.NUMBER).required(false).build(); + + List dtos = List.of(dto1, dto2); + + // When + List result = mapper.toPropertyDefinitionEntities(dtos); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("prop1"); + assertThat(result.get(1).name()).isEqualTo("prop2"); } - @Nested - @DisplayName("PropertyDefinition Mapping Tests") - class PropertyDefinitionMappingTests { - - @Test - @DisplayName("Should map PropertyDefinitionDtoIn to PropertyDefinition") - void shouldMapPropertyDtoInToEntity() { - // Given - var rules = PropertyRulesDtoIn.builder() - .format(PropertyFormat.EMAIL) - .enumValues(new String[]{"value1", "value2"}) - .regex(".*@.*") - .maxLength(100) - .minLength(5) - .maxValue(10) - .minValue(1) - .build(); - - var dto = PropertyDefinitionDtoIn.builder() - .name("email") - .description("User email address") - .type(PropertyType.STRING) - .required(true) - .rules(rules) - .build(); - - // When - PropertyDefinition result = mapper.toToPropertyDefinition(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.name()).isEqualTo("email"); - assertThat(result.description()).isEqualTo("User email address"); - assertThat(result.type()).isEqualTo(PropertyType.STRING); - assertThat(result.required()).isTrue(); - assertThat(result.rules()).isNotNull(); - assertThat(result.rules().format()).isEqualTo(PropertyFormat.EMAIL); - } - - @Test - @DisplayName("Should handle null PropertyDefinitionDtoIn") - void shouldHandleNullPropertyDtoIn() { - // When - PropertyDefinition result = mapper.toToPropertyDefinition((PropertyDefinitionDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map PropertyDefinition to PropertyDefinitionDtoOut") - void shouldMapPropertyEntityToDtoOut() { - // Given - var rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - List.of("value1", "value2"), - ".*@.*", - 100, - 5, - 10, - 1 - ); - - var entity = new PropertyDefinition( - UUID.randomUUID(), - "email", - "User email address", - PropertyType.STRING, - true, - rules - ); - - // When - PropertyDefinitionDtoOut result = mapper.toDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getName()).isEqualTo("email"); - assertThat(result.getDescription()).isEqualTo("User email address"); - assertThat(result.getType()).isEqualTo(PropertyType.STRING); - assertThat(result.isRequired()).isTrue(); - assertThat(result.getRules()).isNotNull(); - } - - @Test - @DisplayName("Should handle null PropertyDefinition") - void shouldHandleNullPropertyEntity() { - // When - PropertyDefinitionDtoOut result = mapper.toDto((PropertyDefinition) null); - - // Then - assertThat(result).isNull(); - } + @Test + @DisplayName("Should map list of PropertyDefinition to list of PropertyDefinitionDtoOut") + void shouldMapPropertyEntityListToDtoOutList() { + // Given + var entity1 = new PropertyDefinition(UUID.randomUUID(), "prop1", "Property 1", + PropertyType.STRING, true, null); + + var entity2 = new PropertyDefinition(UUID.randomUUID(), "prop2", "Property 2", + PropertyType.NUMBER, false, null); + + List entities = List.of(entity1, entity2); + + // When + List result = mapper.toPropertyDefinitionDtos(entities); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getName()).isEqualTo("prop1"); + assertThat(result.get(1).getName()).isEqualTo("prop2"); } - @Nested - @DisplayName("PropertyRules Mapping Tests") - class PropertyRulesMappingTests { - - @Test - @DisplayName("Should map PropertyRulesDtoIn to PropertyRules") - void shouldMapRulesDtoInToEntity() { - // Given - var dto = PropertyRulesDtoIn.builder() - .format(PropertyFormat.URL) - .enumValues(new String[]{"HTTP", "HTTPS"}) - .regex("^https?://.*") - .maxLength(500) - .minLength(10) - .maxValue(100) - .minValue(1) - .build(); - - // When - PropertyRules result = mapper.toPropertyRules(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.format()).isEqualTo(PropertyFormat.URL); - assertThat(result.enumValues()).containsExactly("HTTP", "HTTPS"); - assertThat(result.regex()).isEqualTo("^https?://.*"); - assertThat(result.maxLength()).isEqualTo(500); - assertThat(result.minLength()).isEqualTo(10); - assertThat(result.maxValue()).isEqualTo(100); - assertThat(result.minValue()).isEqualTo(1); - } - - @Test - @DisplayName("Should normalize enum_values to uppercase") - void shouldNormalizeEnumValuesToUppercase() { - // Given - var dto = PropertyRulesDtoIn.builder() - .enumValues(new String[]{"EMAil", "postal_code", "ACTIVE"}) - .build(); - - // When - PropertyRules result = mapper.toPropertyRules(dto); - - // Then - assertThat(result.enumValues()).containsExactly("EMAIL", "POSTAL_CODE", "ACTIVE"); - } - - @Test - @DisplayName("Should handle null PropertyRulesDtoIn") - void shouldHandleNullRulesDtoIn() { - // When - PropertyRules result = mapper.toPropertyRules((PropertyRulesDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map PropertyRules to PropertyRulesDtoOut") - void shouldMapRulesEntityToDtoOut() { - // Given - var entity = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.URL, - List.of("http", "https"), - "^https?://.*", - 500, - 10, - 100, - 1 - ); - - // When - PropertyRulesDtoOut result = mapper.toDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getFormat()).isEqualTo(PropertyFormat.URL); - assertThat(result.getEnumValues()).containsExactly("http", "https"); - assertThat(result.getRegex()).isEqualTo("^https?://.*"); - assertThat(result.getMaxLength()).isEqualTo(500); - assertThat(result.getMinLength()).isEqualTo(10); - assertThat(result.getMaxValue()).isEqualTo(100); - assertThat(result.getMinValue()).isEqualTo(1); - } - - @Test - @DisplayName("Should handle null PropertyRules") - void shouldHandleNullRulesEntity() { - // When - PropertyRulesDtoOut result = mapper.toDto((PropertyRules) null); - - // Then - assertThat(result).isNull(); - } + @Test + @DisplayName("Should map list of RelationDefinitionDtoIn to list of RelationDefinition") + void shouldMapRelationDtoInListToEntityList() { + // Given + var dto1 = RelationDefinitionDtoIn.builder().name("rel1").targetTemplateIdentifier("target1") + .required(true).toMany(false).build(); + + var dto2 = RelationDefinitionDtoIn.builder().name("rel2").targetTemplateIdentifier("target2") + .required(false).toMany(true).build(); + + List dtos = List.of(dto1, dto2); + + // When + List result = mapper.toRelationDefinitionEntities(dtos); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("rel1"); + assertThat(result.get(1).name()).isEqualTo("rel2"); } - @Nested - @DisplayName("RelationDefinition Mapping Tests") - class RelationDefinitionMappingTests { - - @Test - @DisplayName("Should map RelationDefinitionDtoIn to RelationDefinition") - void shouldMapRelationDtoInToEntity() { - // Given - var dto = RelationDefinitionDtoIn.builder() - .name("parentService") - .targetTemplateIdentifier("service") - .required(true) - .toMany(false) - .build(); - - // When - RelationDefinition result = mapper.toRelationDefinition(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.name()).isEqualTo("parentService"); - assertThat(result.targetTemplateIdentifier()).isEqualTo("service"); - assertThat(result.required()).isTrue(); - assertThat(result.toMany()).isFalse(); - } - - @Test - @DisplayName("Should handle null RelationDefinitionDtoIn") - void shouldHandleNullRelationDtoIn() { - // When - RelationDefinition result = mapper.toRelationDefinition((RelationDefinitionDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map RelationDefinition to RelationDefinitionDtoOut") - void shouldMapRelationEntityToDtoOut() { - // Given - var entity = new RelationDefinition( - UUID.randomUUID(), - "childServices", - "service", - false, - true - ); - - // When - RelationDefinitionDtoOut result = mapper.toDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getName()).isEqualTo("childServices"); - assertThat(result.getTargetTemplateIdentifier()).isEqualTo("service"); - assertThat(result.isRequired()).isFalse(); - assertThat(result.isToMany()).isTrue(); - } - - @Test - @DisplayName("Should handle null RelationDefinition") - void shouldHandleNullRelationEntity() { - // When - RelationDefinitionDtoOut result = mapper.toDto((RelationDefinition) null); - - // Then - assertThat(result).isNull(); - } + @Test + @DisplayName("Should map list of RelationDefinition to list of RelationDefinitionDtoOut") + void shouldMapRelationEntityListToDtoOutList() { + // Given + var entity1 = new RelationDefinition(UUID.randomUUID(), "rel1", "target1", true, false); + + var entity2 = new RelationDefinition(UUID.randomUUID(), "rel2", "target2", false, true); + + List entities = List.of(entity1, entity2); + + // When + List result = mapper.toRelationDefinitionDtos(entities); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getName()).isEqualTo("rel1"); + assertThat(result.get(1).getName()).isEqualTo("rel2"); } - @Nested - @DisplayName("List Mapping Tests") - class ListMappingTests { - - @Test - @DisplayName("Should map list of PropertyDefinitionDtoIn to list of PropertyDefinition") - void shouldMapPropertyDtoInListToEntityList() { - // Given - var dto1 = PropertyDefinitionDtoIn.builder() - .name("prop1") - .description("Property 1") - .type(PropertyType.STRING) - .required(true) - .build(); - - var dto2 = PropertyDefinitionDtoIn.builder() - .name("prop2") - .description("Property 2") - .type(PropertyType.NUMBER) - .required(false) - .build(); - - List dtos = List.of(dto1, dto2); - - // When - List result = mapper.toPropertyDefinitionEntities(dtos); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).name()).isEqualTo("prop1"); - assertThat(result.get(1).name()).isEqualTo("prop2"); - } - - @Test - @DisplayName("Should map list of PropertyDefinition to list of PropertyDefinitionDtoOut") - void shouldMapPropertyEntityListToDtoOutList() { - // Given - var entity1 = new PropertyDefinition( - UUID.randomUUID(), - "prop1", - "Property 1", - PropertyType.STRING, - true, - null - ); - - var entity2 = new PropertyDefinition( - UUID.randomUUID(), - "prop2", - "Property 2", - PropertyType.NUMBER, - false, - null - ); - - List entities = List.of(entity1, entity2); - - // When - List result = mapper.toPropertyDefinitionDtos(entities); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getName()).isEqualTo("prop1"); - assertThat(result.get(1).getName()).isEqualTo("prop2"); - } - - @Test - @DisplayName("Should map list of RelationDefinitionDtoIn to list of RelationDefinition") - void shouldMapRelationDtoInListToEntityList() { - // Given - var dto1 = RelationDefinitionDtoIn.builder() - .name("rel1") - .targetTemplateIdentifier("target1") - .required(true) - .toMany(false) - .build(); - - var dto2 = RelationDefinitionDtoIn.builder() - .name("rel2") - .targetTemplateIdentifier("target2") - .required(false) - .toMany(true) - .build(); - - List dtos = List.of(dto1, dto2); - - // When - List result = mapper.toRelationDefinitionEntities(dtos); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).name()).isEqualTo("rel1"); - assertThat(result.get(1).name()).isEqualTo("rel2"); - } - - @Test - @DisplayName("Should map list of RelationDefinition to list of RelationDefinitionDtoOut") - void shouldMapRelationEntityListToDtoOutList() { - // Given - var entity1 = new RelationDefinition( - UUID.randomUUID(), - "rel1", - "target1", - true, - false - ); - - var entity2 = new RelationDefinition( - UUID.randomUUID(), - "rel2", - "target2", - false, - true - ); - - List entities = List.of(entity1, entity2); - - // When - List result = mapper.toRelationDefinitionDtos(entities); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getName()).isEqualTo("rel1"); - assertThat(result.get(1).getName()).isEqualTo("rel2"); - } - - @Test - @DisplayName("Should handle null lists") - void shouldHandleNullLists() { - // When & Then - assertThat(mapper.toPropertyDefinitionEntities(null)).isEmpty(); - assertThat(mapper.toPropertyDefinitionDtos(null)).isEmpty(); - assertThat(mapper.toRelationDefinitionEntities(null)).isEmpty(); - assertThat(mapper.toRelationDefinitionDtos(null)).isEmpty(); - } + @Test + @DisplayName("Should handle null lists") + void shouldHandleNullLists() { + // When & Then + assertThat(mapper.toPropertyDefinitionEntities(null)).isEmpty(); + assertThat(mapper.toPropertyDefinitionDtos(null)).isEmpty(); + assertThat(mapper.toRelationDefinitionEntities(null)).isEmpty(); + assertThat(mapper.toRelationDefinitionDtos(null)).isEmpty(); } + } } From d8b88303576ec0954b8543b92a8ce154ea3b8fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 28 May 2026 18:42:08 +0200 Subject: [PATCH 23/27] feat(core): add a entity graph service and endpoint --- .../domain/constant/ValidationMessages.java | 156 +- .../exception/InvalidQueryDslException.java | 6 +- .../entity/EntityAlreadyExistsException.java | 14 +- .../entity/EntityNotFoundException.java | 22 +- .../entity/EntityValidationException.java | 29 +- ...pertyDefinitionRulesConflictException.java | 18 +- .../idp_core/domain/model/entity/Entity.java | 29 +- .../domain/model/entity/EntityFilter.java | 24 +- .../domain/model/entity/FilterCriterion.java | 8 +- .../domain/model/entity/Property.java | 11 +- .../model/entity_template/EntityTemplate.java | 33 +- .../domain/model/enums/FilterKeyType.java | 8 +- .../domain/model/enums/FilterOperator.java | 5 +- .../domain/port/EntityRepositoryPort.java | 24 +- .../service/EntityQueryParserService.java | 406 ++-- .../domain/service/entity/EntityService.java | 220 +- .../entity/EntityValidationService.java | 142 +- .../domain/service/entity/Violations.java | 44 +- .../PropertyDefinitionValidationService.java | 530 ++--- .../property/PropertyValidationService.java | 224 +- .../service/relation/RelationService.java | 31 +- .../relation/RelationValidationService.java | 160 +- .../api/configuration/CorsProperties.java | 15 +- .../api/configuration/SwaggerDescription.java | 304 +-- .../api/controller/EntityController.java | 253 +- .../api/dto/in/EntityCreateDtoIn.java | 25 +- .../api/dto/in/EntityDtoInCommonFields.java | 67 +- .../api/dto/in/EntityUpdateDtoIn.java | 17 +- .../api/handler/ApiExceptionHandler.java | 659 +++--- .../api/mapper/entity/EntityDtoInMapper.java | 83 +- .../api/mapper/entity/EntityDtoOutMapper.java | 455 ++-- .../persistence/PostgresEntityAdapter.java | 121 +- .../model/entity/EntityJpaEntity.java | 40 +- .../repository/JpaEntityRepository.java | 194 +- .../specification/EntitySpecification.java | 366 ++- .../service/EntityQueryParserServiceTest.java | 991 ++++---- .../service/entity/EntityServiceTest.java | 413 ++-- .../entity/EntityValidationServiceTest.java | 196 +- .../PropertyValidationServiceTest.java | 693 +++--- .../RelationValidationServiceTest.java | 322 ++- .../api/controller/EntityControllerTest.java | 1130 +++++---- .../EntityTemplateControllerTest.java | 2055 ++++++++--------- .../api/handler/ApiExceptionHandlerTest.java | 911 ++++---- .../mapper/entity/EntityDtoInMapperTest.java | 144 +- .../EntitySpecificationTest.java | 85 +- .../db/test/R__1_Insert_test_data.sql | 99 - .../test/R__2_Insert_entities_test_data.sql | 152 +- 47 files changed, 5999 insertions(+), 5935 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 56c9dff..219b42b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -6,93 +6,89 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValidationMessages { - // Entity Template validation messages - public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; - public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; - public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; - public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; - public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; - public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; - public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; - public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + // Entity Template validation messages + public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; + public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; + public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; + public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; + public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; + public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; + public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; + public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; - // Property Definition validation messages - public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; - public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; - public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; - public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; - public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; - public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; - public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; - public static final String PROPERTY_NOT_DEFINED_IN_TEMPLATE = "Property '%s' is not defined in template '%s'"; - public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; - public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; - public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; - public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; - public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; - public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; - public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; - public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + // Property Definition validation messages + public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; + public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; + public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; + public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; + public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_NOT_DEFINED_IN_TEMPLATE = "Property '%s' is not defined in template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - // Property Rules validation messages - templates and specific constraints - public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; - public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; - public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; - public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; - public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; - public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; - // Relation Definition validation messages - public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; - public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; - public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; - public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; - public static final String RELATION_NOT_DEFINED_IN_TEMPLATE = "Relation '%s' is not defined in template '%s'"; - public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'"; - public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'"; - public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist"; - public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; - public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; + // Relation Definition validation messages + public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; + public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; + public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; + public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + public static final String RELATION_NOT_DEFINED_IN_TEMPLATE = "Relation '%s' is not defined in template '%s'"; + public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'"; + public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'"; + public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist"; + public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; + public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; - // Entity input validation messages - public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; - public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; - // Entity creation validation messages - public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; - public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; - public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - // Helper method to construct rules incompatibility message - public static String rulesAreIncompatible(String rule1, String rule2) { - return PROPERTY_RULES_MUTUALLY_EXCLUSIVE - .replace("{rule1}", rule1) - .replace("{rule2}", rule2); - } + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE.replace("{rule1}", rule1).replace("{rule2}", rule2); + } - // Helper method to construct rule-not-allowed message - public static String ruleNotAllowed(String rule, String propertyType) { - return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE - .replace("{rule}", rule) - .replace("{type}", propertyType); - } + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE.replace("{rule}", rule).replace("{type}", + propertyType); + } - // Helper method to construct min/max constraint violation message - public static String minMaxConstraintViolated(String constraint) { - return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED - .replace("{constraint}", constraint); - } + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); + } - // Filter query validation messages - public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; - public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; - public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; - public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; - public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; - public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; - public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; + // Filter query validation messages + public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; + public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; + public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; + public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; + public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; + public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; + public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java b/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java index 895d45f..943d6e4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java @@ -6,7 +6,7 @@ /// This exception should be mapped to HTTP 400 Bad Request by the infrastructure layer. public class InvalidQueryDslException extends RuntimeException { - public InvalidQueryDslException(String message) { - super(message); - } + public InvalidQueryDslException(String message) { + super(message); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index 8243748..fb139cf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -16,11 +16,11 @@ /// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityName the duplicate entity name - public EntityAlreadyExistsException(String templateIdentifier, String entityName) { - super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index 42c60f6..cea5f8e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -16,15 +16,17 @@ /// - Maintains template-entity relationship integrity public class EntityNotFoundException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// **Why this exists:** Provides standardized error message format that includes - /// both template and entity context for clear debugging and API error responses. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityIdentifier the identifier of the entity - public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// **Why this exists:** Provides standardized error message format that + /// includes + /// both template and entity context for clear debugging and API error + /// responses. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityIdentifier the identifier of the entity + public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index 42756f0..0038120 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -23,21 +23,20 @@ @Getter public class EntityValidationException extends RuntimeException { - /** - * -- GETTER -- - * Returns the list of individual validation violation messages. - * /// - * /// - * @return immutable list of violation messages - */ - private final List violations; + /** + * -- GETTER -- Returns the list of individual validation violation messages. + * /// /// + * + * @return immutable list of violation messages + */ + private final List violations; - /// Constructs a new exception with a list of validation violation messages. - /// - /// @param violations the list of validation error messages - public EntityValidationException(List violations) { - super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); - this.violations = List.copyOf(violations); - } + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index 3ce489e..737c7c8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -13,13 +13,13 @@ /// - Property template updates introducing rule conflicts public class PropertyDefinitionRulesConflictException extends RuntimeException { - /// Constructs a new exception for rule type conflict. - /// - /// @param propertyName the name of the property with invalid rules - /// @param propertyType the data type of the property - /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { - super("Property '" + propertyName + "' of type " + propertyType + - ": " + violationMessage); - } + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 7cbe3c3..a059708 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,10 +7,10 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import jakarta.validation.constraints.NotBlank; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Domain entity representing a concrete instance of an [EntityTemplate]. /// /// Business invariants: @@ -23,20 +23,19 @@ /// schema, containing actual values that comply with the template's structure /// and rules. -public record Entity( - UUID id, +public record Entity(UUID id, - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, - List properties, + List properties, - List relations) { - /// Compact constructor defensively copies mutable collections to keep the - /// record immutable. - public Entity { - properties = properties != null ? List.copyOf(properties) : List.of(); - relations = relations != null ? List.copyOf(relations) : List.of(); - } + List relations) { + /// Compact constructor defensively copies mutable collections to keep the + /// record immutable. + public Entity { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java index 97514a4..52d6735 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java @@ -12,18 +12,18 @@ /// Use [EntityFilter#empty()] to represent the absence of any filter constraint. public record EntityFilter(List criteria) { - /// Constructs an [EntityFilter] with a defensive copy of the criteria list. - public EntityFilter { - criteria = criteria != null ? List.copyOf(criteria) : List.of(); - } + /// Constructs an [EntityFilter] with a defensive copy of the criteria list. + public EntityFilter { + criteria = criteria != null ? List.copyOf(criteria) : List.of(); + } - /// Returns an [EntityFilter] with no criteria (matches all entities). - public static EntityFilter empty() { - return new EntityFilter(List.of()); - } + /// Returns an [EntityFilter] with no criteria (matches all entities). + public static EntityFilter empty() { + return new EntityFilter(List.of()); + } - /// Returns true when no criteria have been defined. - public boolean isEmpty() { - return criteria.isEmpty(); - } + /// Returns true when no criteria have been defined. + public boolean isEmpty() { + return criteria.isEmpty(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java index 045c91d..213b7f5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java @@ -13,10 +13,6 @@ /// - [FilterKeyType#RELATION_PROPERTY] — a property (`identifier` or `name`) of the target entity of a named relation. `key` format: `relationName.propertyName` (e.g., `api-link.identifier`) /// /// Multiple [FilterCriterion] instances combined in an [EntityFilter] are applied with implicit AND logic. -public record FilterCriterion( - FilterKeyType keyType, - String key, - FilterOperator operator, - String value -) { +public record FilterCriterion(FilterKeyType keyType, String key, FilterOperator operator, + String value) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 2af3571..c71c02e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -4,12 +4,12 @@ import java.util.UUID; +import jakarta.validation.constraints.NotBlank; + import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import jakarta.validation.constraints.NotBlank; - /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints @@ -26,10 +26,9 @@ /// [PropertyType] definition (carried as [Object] so the original JSON type — /// String, Number, Boolean — is preserved for strict type-mismatch detection /// at validation time). -public record Property( - UUID id, +public record Property(UUID id, - @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, + @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - String value) { + String value) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 04967db..69078ce 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -26,26 +26,25 @@ /// - Relation names must be unique within the template (if any) /// - All property definitions must have valid types and constraints /// - Relations must reference valid target template identifiers -public record EntityTemplate( - UUID id, +public record EntityTemplate(UUID id, - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - String identifier, + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String identifier, - @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) - @NotBlank(message = TEMPLATE_NAME_MANDATORY) - @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) - String name, + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) @NotBlank(message = TEMPLATE_NAME_MANDATORY) @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) String name, - String description, + String description, - List propertiesDefinitions, + List propertiesDefinitions, - List relationsDefinitions -) { - /// Compact constructor defensively copies mutable collections to preserve immutability. - public EntityTemplate { - propertiesDefinitions = propertiesDefinitions != null ? List.copyOf(propertiesDefinitions) : List.of(); - relationsDefinitions = relationsDefinitions != null ? List.copyOf(relationsDefinitions) : List.of(); - } + List relationsDefinitions) { + /// Compact constructor defensively copies mutable collections to preserve + /// immutability. + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions != null + ? List.copyOf(propertiesDefinitions) + : List.of(); + relationsDefinitions = relationsDefinitions != null + ? List.copyOf(relationsDefinitions) + : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java index fbd4e75..1738d9b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java @@ -17,11 +17,5 @@ /// *source* entity in a reverse relation. Key format: `relationName.propertyName` /// (e.g. `relations_as_target.api-link.name:microservice`) public enum FilterKeyType { - ATTRIBUTE, - PROPERTY, - RELATION_NAME, - RELATION_ENTITY, - RELATION_PROPERTY, - RELATIONS_AS_TARGET_NAME, - RELATIONS_AS_TARGET_PROPERTY + ATTRIBUTE, PROPERTY, RELATION_NAME, RELATION_ENTITY, RELATION_PROPERTY, RELATIONS_AS_TARGET_NAME, RELATIONS_AS_TARGET_PROPERTY } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java index 82a443b..cbcc867 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java @@ -8,8 +8,5 @@ /// - [LESS_THAN] requires the field to be less than the value /// - [GREATER_THAN] requires the field to be greater than the value public enum FilterOperator { - EQUALS, - CONTAINS, - LESS_THAN, - GREATER_THAN + EQUALS, CONTAINS, LESS_THAN, GREATER_THAN } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 8e31ffb..0718ea9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -29,23 +29,27 @@ /// appropriately for the underlying persistence technology. public interface EntityRepositoryPort { - Entity save(Entity entity); + Entity save(Entity entity); - Optional findById(UUID id); + Optional findById(UUID id); - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); - Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable); + Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, + Pageable pageable); - List findByIdentifierIn(List identifiers); + List findByIdentifierIn(List identifiers); - List findByRelationIdIn(List relationIds); + List findByRelationIdIn(List relationIds); - void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); + void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames); - void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java index 9333f5a..59ec171 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java @@ -6,10 +6,10 @@ import java.util.Set; import java.util.stream.Stream; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.FilterCriterion; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; @@ -42,237 +42,241 @@ @Service public class EntityQueryParserService { - private static final String RELATION = "relation"; - private static final String RELATIONS_AS_TARGET = "relations_as_target"; - private static final String PROPERTY_PREFIX = "property."; - private static final String RELATION_PREFIX = "relation."; - private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); - - private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( - FilterKeyType.ATTRIBUTE, - FilterKeyType.RELATION_NAME, - FilterKeyType.RELATION_ENTITY, - FilterKeyType.RELATION_PROPERTY, - FilterKeyType.RELATIONS_AS_TARGET_NAME, - FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); - - static final int MAX_CRITERIA_COUNT = 10; - static final int MAX_KEY_VALUE_LENGTH = 255; - - /// Parses a query string into an [EntityFilter]. - /// - /// @param query the raw `q` parameter value; may be null or blank - /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] when query is blank - /// @throws InvalidQueryDslException when the query string is malformed or exceeds safety limits - public EntityFilter parse(String query) { - if (query == null || query.isBlank()) { - return EntityFilter.empty(); - } - - List criteria = Stream.of(query.split(";")) - .filter(token -> !token.isBlank()) - .map(token -> parseCriterion(token.trim())) - .toList(); - - if (criteria.size() > MAX_CRITERIA_COUNT) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(MAX_CRITERIA_COUNT)); - } + private static final String RELATION = "relation"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); + + private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( + FilterKeyType.ATTRIBUTE, FilterKeyType.RELATION_NAME, FilterKeyType.RELATION_ENTITY, + FilterKeyType.RELATION_PROPERTY, FilterKeyType.RELATIONS_AS_TARGET_NAME, + FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); + + static final int MAX_CRITERIA_COUNT = 10; + static final int MAX_KEY_VALUE_LENGTH = 255; + + /// Parses a query string into an [EntityFilter]. + /// + /// @param query the raw `q` parameter value; may be null or blank + /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] + /// when query is blank + /// @throws InvalidQueryDslException when the query string is malformed or + /// exceeds safety limits + public EntityFilter parse(String query) { + if (query == null || query.isBlank()) { + return EntityFilter.empty(); + } - validateNoDuplicates(criteria); + List criteria = Stream.of(query.split(";")).filter(token -> !token.isBlank()) + .map(token -> parseCriterion(token.trim())).toList(); - return new EntityFilter(criteria); + if (criteria.size() > MAX_CRITERIA_COUNT) { + throw new InvalidQueryDslException( + ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(MAX_CRITERIA_COUNT)); } - private FilterCriterion parseCriterion(String token) { - int operatorIndex = findOperatorIndex(token) - .orElseThrow(() -> new InvalidQueryDslException(ValidationMessages.FILTER_INVALID_FORMAT)); + validateNoDuplicates(criteria); - var rawKey = token.substring(0, operatorIndex); - var operatorChar = token.charAt(operatorIndex); - var value = token.substring(operatorIndex + 1); + return new EntityFilter(criteria); + } - validateKey(rawKey, token); - validateValue(value, token); - validateLength(rawKey, value, token); + private FilterCriterion parseCriterion(String token) { + int operatorIndex = findOperatorIndex(token) + .orElseThrow(() -> new InvalidQueryDslException(ValidationMessages.FILTER_INVALID_FORMAT)); - var operator = toOperator(operatorChar); - var criterion = buildCriterion(rawKey, operator, value, token); - validateOperatorCompatibility(criterion.keyType(), operator, rawKey); - return criterion; - } + var rawKey = token.substring(0, operatorIndex); + var operatorChar = token.charAt(operatorIndex); + var value = token.substring(operatorIndex + 1); - private OptionalInt findOperatorIndex(String token) { - for (int i = 0; i < token.length(); i++) { - char c = token.charAt(i); - if (c == '=' || c == ':' || c == '<' || c == '>') { - return OptionalInt.of(i); - } - } - return OptionalInt.empty(); - } + validateKey(rawKey, token); + validateValue(value, token); + validateLength(rawKey, value, token); + + var operator = toOperator(operatorChar); + var criterion = buildCriterion(rawKey, operator, value, token); + validateOperatorCompatibility(criterion.keyType(), operator, rawKey); + return criterion; + } - private FilterOperator toOperator(char c) { - return switch (c) { - case '=' -> FilterOperator.EQUALS; - case ':' -> FilterOperator.CONTAINS; - case '<' -> FilterOperator.LESS_THAN; - case '>' -> FilterOperator.GREATER_THAN; - default -> throw new InvalidQueryDslException("Unknown operator character: " + c); - }; + private OptionalInt findOperatorIndex(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c == '=' || c == ':' || c == '<' || c == '>') { + return OptionalInt.of(i); + } + } + return OptionalInt.empty(); + } + + private FilterOperator toOperator(char c) { + return switch (c) { + case '=' -> FilterOperator.EQUALS; + case ':' -> FilterOperator.CONTAINS; + case '<' -> FilterOperator.LESS_THAN; + case '>' -> FilterOperator.GREATER_THAN; + default -> throw new InvalidQueryDslException("Unknown operator character: " + c); + }; + } + + private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, + String token) { + // Direct attribute filters (relation=X means filter by relation name) + if (RELATION.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); } - private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, String token) { - // Direct attribute filters (relation=X means filter by relation name) - if (RELATION.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); - } - - if (RELATIONS_AS_TARGET.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); - } - - if (rawKey.startsWith(PROPERTY_PREFIX)) { - var keyName = rawKey.substring(PROPERTY_PREFIX.length()); - validateKeyName(keyName, token); - return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); - } - - if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationsAsTargetCriterion(relationPart, operator, value, token); - } - - if (rawKey.startsWith(RELATION_PREFIX)) { - var relationPart = rawKey.substring(RELATION_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationCriterion(relationPart, operator, value, token); - } - - if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { - throw new InvalidQueryDslException( - "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s" - .formatted(rawKey, token, VALID_ATTRIBUTE_NAMES)); - } - return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + if (RELATIONS_AS_TARGET.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); } - private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex <= 0) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" - .formatted(token)); - } - - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, value); + if (rawKey.startsWith(PROPERTY_PREFIX)) { + var keyName = rawKey.substring(PROPERTY_PREFIX.length()); + validateKeyName(keyName, token); + return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); } - private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex > 0) { - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATION, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); - } - - // Default: relation entity filter - validateKeyName(relationPart, token); - return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationsAsTargetCriterion(relationPart, operator, value, token); } - private void validateNoDuplicates(List criteria) { - Set seen = new HashSet<>(); - for (FilterCriterion criterion : criteria) { - String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); - if (!seen.add(dedupeKey)) { - throw new InvalidQueryDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - } + if (rawKey.startsWith(RELATION_PREFIX)) { + var relationPart = rawKey.substring(RELATION_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationCriterion(relationPart, operator, value, token); } - private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, String rawKey) { - if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) && - (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { - var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException(ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); - } + if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { + throw new InvalidQueryDslException( + "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s".formatted(rawKey, + token, VALID_ATTRIBUTE_NAMES)); + } + return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + } + + private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, + FilterOperator operator, String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex <= 0) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" + .formatted(token)); } - /// Validates that all PROPERTY criteria using `<` or `>` operators - /// correspond to a NUMBER-typed property in the given template. - /// - /// This is a semantic check that requires the template to be available (i.e., it - /// cannot be performed in [#parse] which has no template context). - /// - /// @param filter the parsed query filter - /// @param template the entity template providing property type information - /// @throws InvalidQueryDslException when a comparison operator is used on a non-NUMBER property - public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { - filter.criteria().stream() - .filter(c -> c.keyType() == FilterKeyType.PROPERTY) - .filter(c -> c.operator() == FilterOperator.LESS_THAN || c.operator() == FilterOperator.GREATER_THAN) - .forEach(c -> { - var propertyDef = template.propertiesDefinitions().stream() - .filter(p -> p.name().equals(c.key())) - .findFirst(); - if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { - var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException( - ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); - } - }); + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, + value); + } + + private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, + String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATION, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); } - private void validateKey(String key, String token) { - if (key.isBlank()) { + // Default: relation entity filter + validateKeyName(relationPart, token); + return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + } + + private void validateNoDuplicates(List criteria) { + Set seen = new HashSet<>(); + for (FilterCriterion criterion : criteria) { + String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); + if (!seen.add(dedupeKey)) { + throw new InvalidQueryDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + } + } + + private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, + String rawKey) { + if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) + && (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { + var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; + throw new InvalidQueryDslException( + ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); + } + } + + /// Validates that all PROPERTY criteria using `<` or `>` operators + /// correspond to a NUMBER-typed property in the given template. + /// + /// This is a semantic check that requires the template to be available (i.e., + /// it + /// cannot be performed in [#parse] which has no template context). + /// + /// @param filter the parsed query filter + /// @param template the entity template providing property type information + /// @throws InvalidQueryDslException when a comparison operator is used on a + /// non-NUMBER property + public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { + filter.criteria().stream().filter(c -> c.keyType() == FilterKeyType.PROPERTY) + .filter(c -> c.operator() == FilterOperator.LESS_THAN + || c.operator() == FilterOperator.GREATER_THAN) + .forEach(c -> { + var propertyDef = template.propertiesDefinitions().stream() + .filter(p -> p.name().equals(c.key())).findFirst(); + if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { + var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; throw new InvalidQueryDslException( - "Invalid filter criterion '%s': key must not be blank".formatted(token)); - } + ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); + } + }); + } + + private void validateKey(String key, String token) { + if (key.isBlank()) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': key must not be blank".formatted(token)); } + } - private void validateKeyName(String keyName, String token) { - if (keyName.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': key name must not be blank".formatted(token)); - } + private void validateKeyName(String keyName, String token) { + if (keyName.isBlank()) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': key name must not be blank".formatted(token)); } + } - private void validateValue(String value, String token) { - if (value.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': value must not be blank".formatted(token)); - } + private void validateValue(String value, String token) { + if (value.isBlank()) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': value must not be blank".formatted(token)); } + } - private void validatePropertyName(String propertyName, String contextType, String token) { - if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { - throw new InvalidQueryDslException( - "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" - .formatted(propertyName, token, contextType)); - } + private void validatePropertyName(String propertyName, String contextType, String token) { + if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { + throw new InvalidQueryDslException( + "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" + .formatted(propertyName, token, contextType)); } + } - private void validateLength(String rawKey, String value, String token) { - if (rawKey.length() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_KEY_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); - } - if (value.length() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); - } + private void validateLength(String rawKey, String value, String token) { + if (rawKey.length() > MAX_KEY_VALUE_LENGTH) { + throw new InvalidQueryDslException( + ValidationMessages.FILTER_KEY_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); + } + if (value.length() > MAX_KEY_VALUE_LENGTH) { + throw new InvalidQueryDslException( + ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index a5b0c6c..efb1de2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,6 +2,9 @@ import java.util.List; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -20,8 +23,6 @@ import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. @@ -40,117 +41,122 @@ @Validated @RequiredArgsConstructor public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityValidationService entityValidationService; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityTemplateService entityTemplateService; - private final EntityQueryParserService entityQueryParserService; - - /// Retrieves entities filtered by template with optional query filter. - /// - /// **Contract:** Returns paginated entities conforming to the specified template - /// that additionally satisfy all criteria in filter (when provided). Template - /// existence is validated first. When filter is null or empty, the result - /// includes all entities for the template. - /// - /// @param pageable pagination configuration for large entity sets - /// @param templateIdentifier business identifier of the entity template - /// @param entityFilter the parsed query filter; null or [EntityFilter#empty()] for no filtering - /// @return paginated entities matching the template and all filter criteria - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public Page getEntitiesByTemplateIdentifier( - Pageable pageable, String templateIdentifier, EntityFilter entityFilter) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); - EntityFilter filter = entityFilter != null ? entityFilter : EntityFilter.empty(); - entityQueryParserService.validateFilterPropertyTypes(filter, template); - return entityRepository.findByTemplateIdentifierWithFilter(templateIdentifier, filter, pageable); - } + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; + private final EntityQueryParserService entityQueryParserService; - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, optimized - /// for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers - public List getEntitiesSummariesByIdentifiers(List identifiers) { - return entityRepository.findByIdentifierIn(identifiers); - } + /// Retrieves entities filtered by template with optional query filter. + /// + /// **Contract:** Returns paginated entities conforming to the specified + /// template + /// that additionally satisfy all criteria in filter (when provided). Template + /// existence is validated first. When filter is null or empty, the result + /// includes all entities for the template. + /// + /// @param pageable pagination configuration for large entity sets + /// @param templateIdentifier business identifier of the entity template + /// @param entityFilter the parsed query filter; null or [EntityFilter#empty()] + /// for no filtering + /// @return paginated entities matching the template and all filter criteria + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier, + EntityFilter entityFilter) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + EntityFilter filter = entityFilter != null ? entityFilter : EntityFilter.empty(); + entityQueryParserService.validateFilterPropertyTypes(filter, template); + return entityRepository.findByTemplateIdentifierWithFilter(templateIdentifier, filter, + pageable); + } - /// Retrieves a specific entity with template and entity validation. - /// - /// **Contract:** Returns the entity identified by both template and entity - /// identifiers. Validates template existence first, then entity existence, - /// ensuring referential integrity. - /// - /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within - /// template - /// @return the entity matching both identifiers - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist - @Transactional - public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, - entityIdentifier)); - } + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized + /// for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers + public List getEntitiesSummariesByIdentifiers(List identifiers) { + return entityRepository.findByIdentifierIn(identifiers); + } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Resolves the referenced template (single round-trip — combined - /// existence check and fetch), enforces entity identifier uniqueness within the - /// template scope, then validates entity/property data integrity against the - /// resolved template before persisting. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers - /// @throws EntityTemplateNotFoundException when the referenced template doesn't - /// exist - /// @throws EntityAlreadyExistsException when an entity with the same - /// identifier already exists for this - /// template - /// @throws EntityValidationException when entity, property, or relation - /// data is invalid - @Transactional - public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateForCreation(entity, template); - return entityRepository.save(entity); - } + /// Retrieves a specific entity with template and entity validation. + /// + /// **Contract:** Returns the entity identified by both template and entity + /// identifiers. Validates template existence first, then entity existence, + /// ensuring referential integrity. + /// + /// @param templateIdentifier business identifier of the entity template + /// @param entityIdentifier unique business identifier of the entity within + /// template + /// @return the entity matching both identifiers + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist + @Transactional + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, + String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + } - /// Updates an existing entity identified by template and entity identifiers. - /// - /// **Contract:** Validates template existence, then entity existence within the - /// template scope. Validates updated entity data against the template constraints - /// before persisting changes. - /// - /// @param templateIdentifier template identifier from the request path - /// @param entityIdentifier entity identifier from the request path - /// @param entity validated entity payload - /// @return persisted updated entity - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when target entity doesn't exist - /// @throws EntityValidationException when payload violates template constraints - @Transactional - public Entity updateEntity(String templateIdentifier, String entityIdentifier, @Valid Entity entity) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); - Entity existingEntity = entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't + /// exist + /// @throws EntityAlreadyExistsException when an entity with the same + /// identifier already exists for this + /// template + /// @throws EntityValidationException when entity, property, or relation + /// data is invalid + @Transactional + public Entity createEntity(@Valid Entity entity) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateForCreation(entity, template); + return entityRepository.save(entity); + } - Entity entityToSave = new Entity( - existingEntity.id(), - templateIdentifier, - entity.name(), - entityIdentifier, - entity.properties(), - entity.relations()); + /// Updates an existing entity identified by template and entity identifiers. + /// + /// **Contract:** Validates template existence, then entity existence within the + /// template scope. Validates updated entity data against the template + /// constraints + /// before persisting changes. + /// + /// @param templateIdentifier template identifier from the request path + /// @param entityIdentifier entity identifier from the request path + /// @param entity validated entity payload + /// @return persisted updated entity + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when target entity doesn't exist + /// @throws EntityValidationException when payload violates template constraints + @Transactional + public Entity updateEntity(String templateIdentifier, String entityIdentifier, + @Valid Entity entity) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + Entity existingEntity = entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - entityValidationService.validateForUpdate(entityToSave, template); - return entityRepository.save(entityToSave); - } + Entity entityToSave = new Entity(existingEntity.id(), templateIdentifier, entity.name(), + entityIdentifier, entity.properties(), entity.relations()); + entityValidationService.validateForUpdate(entityToSave, template); + return entityRepository.save(entityToSave); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index a37a6ee..f70d668 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -29,72 +29,82 @@ @AllArgsConstructor public class EntityValidationService { - private final EntityRepositoryPort entityRepository; - private final PropertyValidationService propertyValidationService; - private final RelationValidationService relationValidationService; - - /// Validates intrinsic entity data integrity and template-driven rules. - /// - /// **Contract:** the caller is responsible for resolving the [EntityTemplate] - /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) - /// and passing it in. This avoids a redundant database round-trip and clarifies - /// the dependency graph of the validation service. - /// - /// @param entity the entity to validate - /// @param template the already-resolved template the entity must conform to - /// @throws EntityValidationException when one or more validation rules are violated - /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateForCreation(Entity entity, EntityTemplate template) { - validateUniqueness(entity); - validateAgainstTemplate(template, entity); - } - - /// Validates entity data for update operations. - /// - /// **Contract:** update keeps the existing aggregate identity and applies the - /// same template conformance rules as creation. Uniqueness check is not needed - /// when updating an already identified entity. - /// - /// @param entity the entity payload to validate - /// @param template the already-resolved template the entity must conform to - /// @throws EntityValidationException when one or more validation rules are violated - void validateForUpdate(Entity entity, EntityTemplate template) { - validateAgainstTemplate(template, entity); - } - - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. - /// @param template the entity template whose property definitions are used for validation - /// @param entity the entity being validated, containing the actual property values to check - /// @throws EntityValidationException if any property validation rules are violated, including missing required properties - private void validateAgainstTemplate(EntityTemplate template, - Entity entity) { - Violations violations = new Violations(); - - List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); - - Map propertiesByName = Optional.ofNullable(entity.properties()).orElse(List.of()).stream() - .filter(p -> p.name() != null) - .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); - - propertyValidationService.validatePropertiesAgainstTemplate(template, definitions, propertiesByName, violations); - - relationValidationService.validateRelationsAgainstTemplate(template, entity.relations(), violations); - - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); - } + private final EntityRepositoryPort entityRepository; + private final PropertyValidationService propertyValidationService; + private final RelationValidationService relationValidationService; + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via + /// [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. + /// + /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are + /// violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier + /// exists for the template + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity); + } + + /// Validates entity data for update operations. + /// + /// **Contract:** update keeps the existing aggregate identity and applies the + /// same template conformance rules as creation. Uniqueness check is not needed + /// when updating an already identified entity. + /// + /// @param entity the entity payload to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are + /// violated + void validateForUpdate(Entity entity, EntityTemplate template) { + validateAgainstTemplate(template, entity); + } + + /// Validates entity properties against the template's property definitions, + /// enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for + /// validation + /// @param entity the entity being validated, containing the actual property + /// values to check + /// @throws EntityValidationException if any property validation rules are + /// violated, including missing required properties + private void validateAgainstTemplate(EntityTemplate template, Entity entity) { + Violations violations = new Violations(); + + List definitions = Optional.ofNullable(template.propertiesDefinitions()) + .orElse(List.of()); + + Map propertiesByName = Optional.ofNullable(entity.properties()) + .orElse(List.of()).stream().filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + propertyValidationService.validatePropertiesAgainstTemplate(template, definitions, + propertiesByName, violations); + + relationValidationService.validateRelationsAgainstTemplate(template, entity.relations(), + violations); + + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); } - - - /// Checks for existing entity with same template and identifier to prevent duplicates. - /// @param entity the entity to check for existence - /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - private void validateUniqueness(final Entity entity) { - if (entity.identifier() != null - && entityRepository - .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) - .isPresent()) { - throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); - } + } + + /// Checks for existing entity with same template and identifier to prevent + /// duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and + /// identifier already exists + private void validateUniqueness(final Entity entity) { + if (entity.identifier() != null && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java index a51293a..2324ddc 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -9,33 +9,33 @@ /// validators stay focused on the rule they enforce rather than on string /// concatenation. Not thread-safe; intended for short-lived per-request use. public final class Violations { - private final List messages = new ArrayList<>(); + private final List messages = new ArrayList<>(); - void add(String message) { - messages.add(message); - } + void add(String message) { + messages.add(message); + } - public void add(String template, Object... args) { - messages.add(template.formatted(args)); - } + public void add(String template, Object... args) { + messages.add(template.formatted(args)); + } - void addIfBlank(String value, String message) { - if (value == null || value.isBlank()) { - messages.add(message); - } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); } + } - /// Adds a violation prefixed with the indexed collection name, e.g. - /// `Property[2]: Property name is mandatory`. - public void addIndexed(String collection, int index, String message) { - messages.add("%s[%d]: %s".formatted(collection, index, message)); - } + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + public void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } - boolean isEmpty() { - return messages.isEmpty(); - } + boolean isEmpty() { + return messages.isEmpty(); + } - List asList() { - return List.copyOf(messages); - } + List asList() { + return List.copyOf(messages); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 79647cc..d61a4bd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -1,20 +1,5 @@ package com.decathlon.idp_core.domain.service.entity_template; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; -import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; -import com.decathlon.idp_core.domain.model.enums.PropertyType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; @@ -23,6 +8,24 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +import lombok.RequiredArgsConstructor; + /// Domain service for validating property definitions and their compatibility with property types. /// /// **Business rules:** @@ -41,305 +44,264 @@ @RequiredArgsConstructor public class PropertyDefinitionValidationService { - private final PropertyRegexValidationService propertyRegexValidationService; + private final PropertyRegexValidationService propertyRegexValidationService; - // Rule name constants - public static final String REGEX = "regex"; - public static final String LENGTH = "length"; - public static final String VALUE = "value"; - public static final String FORMAT = "format"; - public static final String ENUM_VALUES = "enum_values"; - public static final String MAX_LENGTH = "max_length"; - public static final String MIN_LENGTH = "min_length"; - public static final String MAX_VALUE = "max_value"; - public static final String MIN_VALUE = "min_value"; + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; - /// Validates that all property names are unique within a template. - /// - /// **Contract:** Enforces the invariant that property names must be unique. Used - /// during template creation and updates to prevent duplicate property - /// definitions. - /// - /// @param properties the list of property definitions to validate - /// @throws PropertyNameAlreadyExistsException if duplicate property names - /// are found - public void validatePropertyNamesUniqueness(List properties) { - Set names = new HashSet<>(); - for (PropertyDefinition property : properties) { - if (property.name() != null) { - String normalizedName = property.name().toLowerCase(Locale.ROOT); - if (!names.add(normalizedName)) { - throw new PropertyNameAlreadyExistsException(property.name()); - } - } + /// Validates that all property names are unique within a template. + /// + /// **Contract:** Enforces the invariant that property names must be unique. + /// Used + /// during template creation and updates to prevent duplicate property + /// definitions. + /// + /// @param properties the list of property definitions to validate + /// @throws PropertyNameAlreadyExistsException if duplicate property names + /// are found + public void validatePropertyNamesUniqueness(List properties) { + Set names = new HashSet<>(); + for (PropertyDefinition property : properties) { + if (property.name() != null) { + String normalizedName = property.name().toLowerCase(Locale.ROOT); + if (!names.add(normalizedName)) { + throw new PropertyNameAlreadyExistsException(property.name()); } + } } + } - /// Validates that property types are not changed on existing properties. - /// - /// **Contract:** Enforces the invariant that property types cannot be modified - /// after initial creation. Any attempt to change a property type is forbidden. - /// Users must delete and recreate the property if they need to change its type. - /// - /// @param existingProperties the existing property definitions - /// @param incomingProperties the new/updated property definitions - /// @throws PropertyTypeChangeException if any property type change is attempted - public void validateTypeChanges(List existingProperties, List incomingProperties) { - if (existingProperties == null || existingProperties.isEmpty() || - incomingProperties == null || incomingProperties.isEmpty()) { - return; - } - Map updatedMap = incomingProperties.stream() - .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); + /// Validates that property types are not changed on existing properties. + /// + /// **Contract:** Enforces the invariant that property types cannot be modified + /// after initial creation. Any attempt to change a property type is forbidden. + /// Users must delete and recreate the property if they need to change its type. + /// + /// @param existingProperties the existing property definitions + /// @param incomingProperties the new/updated property definitions + /// @throws PropertyTypeChangeException if any property type change is attempted + public void validateTypeChanges(List existingProperties, + List incomingProperties) { + if (existingProperties == null || existingProperties.isEmpty() || incomingProperties == null + || incomingProperties.isEmpty()) { + return; + } + Map updatedMap = incomingProperties.stream() + .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); - for (PropertyDefinition existing : existingProperties) { - PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); - boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); + for (PropertyDefinition existing : existingProperties) { + PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); + boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); - if (propertyTypeChanged) { - throw new PropertyTypeChangeException( - existing.name(), - existing.type(), - updated.type()); - } - } + if (propertyTypeChanged) { + throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type()); + } } + } - /// Validates property rules are compatible with the property's data type. - /// - /// **Contract:** Performs comprehensive validation including: - /// - Rule type compatibility with property type - /// - Numeric constraint ordering (min ≤ max) - /// - Boolean properties reject all rules - /// - /// @param propertyDefinition the property definition containing type and rules - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { - if (propertyDefinition.rules() == null) { - return; - } + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } - PropertyRules rules = propertyDefinition.rules(); - PropertyType type = propertyDefinition.type(); + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); - switch (type) { - case STRING: - validateStringPropertyRules(propertyDefinition.name(), rules); - break; - case NUMBER: - validateNumberPropertyRules(propertyDefinition.name(), rules); - break; - case BOOLEAN: - validateBooleanPropertyRules(propertyDefinition.name(), rules); - break; - default: - throw new IllegalArgumentException("Unknown property type: " + type); - } + switch (type) { + case STRING : + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER : + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN : + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default : + throw new IllegalArgumentException("Unknown property type: " + type); } + } - /// Validates rules for STRING property type. - /// - /// **Allowed rules:** format, enum_values, regex, max_length, min_length - /// **Rejected rules:** max_value, min_value (numeric) - /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; - /// enum_values is also mutually exclusive with max_length and min_length - /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints - private void validateStringPropertyRules(String propertyName, PropertyRules rules) { - validateStringIncompatibleRules(propertyName, rules); - validateStringConstraints(propertyName, rules); + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually + /// exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate + /// any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); - // Validate regex pattern is valid - if (rules.regex() != null && !rules.regex().isBlank()) { - propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); - } + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); } + } - /// Validates numeric constraints for STRING property rules. - /// - /// **Constraints enforced:** - /// - min_length must be non-negative (≥ 0) - /// - max_length must be positive (> 0) - /// - min_length must be less than or equal to max_length - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any constraint is violated - private void validateStringConstraints(String propertyName, PropertyRules rules) { - // Validate min_length is non-negative - if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE - ); - } - // Validate max_length is not zero or negative - if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MAX_LENGTH_POSITIVE - ); - } - // Validate min_length is below or equal to max_length - if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - minMaxConstraintViolated(LENGTH) - ); - } + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is + /// violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE); } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null + && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + minMaxConstraintViolated(LENGTH)); + } + } - /// Validates rule compatibility and mutual exclusivity for STRING property rules. - /// - /// **Incompatibility rules enforced:** - /// - Numeric rules (max_value, min_value) are not allowed for STRING type - /// - format, regex, and enum_values are mutually exclusive - /// - enum_values and length constraints (max_length, min_length) are mutually exclusive - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present - private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { - // Reject numeric rules for STRING type - if (rules.maxValue() != null || rules.minValue() != null) { - String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) - ); - } - - // format, regex, and enum_values are incompatible with each other - if (rules.format() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, ENUM_VALUES) - ); - } - if (rules.format() != null && rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, REGEX) - ); - } - if (rules.regex() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(REGEX, ENUM_VALUES) - ); - } + /// Validates rule compatibility and mutual exclusivity for STRING property + /// rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually + /// exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are + /// both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName)); + } - // enum_values and length constraints are incompatible with each other - if (rules.enumValues() != null && rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) - ); - } - if (rules.enumValues() != null && rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) - ); - } + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES)); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX)); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES)); + } + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH)); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH)); } - /// Validates rules for NUMBER property type. - /// - /// **Allowed rules:** max_value, min_value - /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) - /// **Constraints:** min_value ≤ max_value - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when string rules are present - /// or min/max value constraints are violated - private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) - ); - } + } - if (rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) - ); - } + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length + /// (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are + /// present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name())); + } - if (rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) - ); - } + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name())); + } - if (rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name())); + } - if (rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name())); + } - if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - minMaxConstraintViolated(VALUE) - ); - } + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name())); + } + + if (rules.minValue() != null && rules.maxValue() != null + && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + minMaxConstraintViolated(VALUE)); } + } - /// Validates rules for BOOLEAN property type. - /// - /// **Allowed rules:** None - /// **Rejected rules:** All rules must be null or empty - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN - private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null || - rules.enumValues() != null || - rules.regex() != null || - rules.maxLength() != null || - rules.minLength() != null || - rules.maxValue() != null || - rules.minValue() != null) { + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for + /// BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || rules.enumValues() != null || rules.regex() != null + || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null + || rules.minValue() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.BOOLEAN, - PROPERTY_RULES_BOOLEAN_NOT_ALLOWED - ); - } + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index a01a2dd..f1e8ff4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -31,109 +31,127 @@ import com.decathlon.idp_core.domain.service.entity.Violations; /** - * Domain service validating entity property values against template definitions. + * Domain service validating entity property values against template + * definitions. */ @Service public class PropertyValidationService { - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); - private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); - - /// Cache of compiled regex patterns keyed by their source string. - /// Avoids recompiling the same pattern on every property validation call. - private final Map patternCache = new ConcurrentHashMap<>(); - - /** - * Validates a concrete property value against its property definition. - * The value's runtime Java type is checked first against the expected - * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, - * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is - * normalized to a string and the type-specific rules are evaluated. - * - * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value preserving its original JSON type - * @return list of violations for this value; empty when valid - */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { - return switch (propertyDefinition.type()) { - case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); - }; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + + /** + * Validates a concrete property value against its property definition. The + * value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, BOOLEAN ⇒ + * {@link Boolean}). When the type matches, the value is normalized to a string + * and the type-specific rules are evaluated. + * + * @param propertyDefinition + * property definition with expected type and optional rules + * @param rawValue + * raw property value preserving its original JSON type + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, + Object rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + /// Validates that all required properties defined in the template are present + /// and conform to their definitions. + /// Also validates that all provided properties are actually defined in the + /// template. + /// For each property definition, checks if the corresponding property is + /// provided and non-blank. If a required property is missing, adds a violation. + /// If the property is present, validates its value against the definition's + /// rules and accumulates any violations found. + /// @param template the entity template whose property definitions are used for + /// validation + /// @param definitions the list of property definitions from the template + /// @param propertiesByName a map of provided properties keyed by their name for + /// quick lookup + /// @param violations the accumulator for any validation violations found during + /// the process + /// @throws EntityValidationException if any required property is missing or if + /// any property value violates its definition rules + /// @implNote This method focuses on validating the presence and correctness of + /// properties as defined by the template. It iterates through each property + /// definition, checks for the corresponding provided property, and applies the + /// appropriate validation logic based on the property's type and rules. + public void validatePropertiesAgainstTemplate(final EntityTemplate template, + final List definitions, final Map propertiesByName, + final Violations violations) { + var definedPropertyNames = definitions.stream().map(PropertyDefinition::name) + .collect(Collectors.toSet()); + + for (String providedPropertyName : propertiesByName.keySet()) { + if (!definedPropertyNames.contains(providedPropertyName)) { + violations.add(PROPERTY_NOT_DEFINED_IN_TEMPLATE, providedPropertyName, + template.identifier()); + } } - /// Validates that all required properties defined in the template are present and conform to their definitions. - /// Also validates that all provided properties are actually defined in the template. - /// For each property definition, checks if the corresponding property is provided and non-blank. If a required property is missing, adds a violation. If the property is present, validates its value against the definition's rules and accumulates any violations found. - /// @param template the entity template whose property definitions are used for validation - /// @param definitions the list of property definitions from the template - /// @param propertiesByName a map of provided properties keyed by their name for quick lookup - /// @param violations the accumulator for any validation violations found during the process - /// @throws EntityValidationException if any required property is missing or if any property value violates its definition rules - /// @implNote This method focuses on validating the presence and correctness of properties as defined by the template. It iterates through each property definition, checks for the corresponding provided property, and applies the appropriate validation logic based on the property's type and rules. - public void validatePropertiesAgainstTemplate(final EntityTemplate template, final List definitions, final Map propertiesByName, final Violations violations) { - var definedPropertyNames = definitions.stream() - .map(PropertyDefinition::name) - .collect(Collectors.toSet()); - - for (String providedPropertyName : propertiesByName.keySet()) { - if (!definedPropertyNames.contains(providedPropertyName)) { - violations.add(PROPERTY_NOT_DEFINED_IN_TEMPLATE, providedPropertyName, template.identifier()); - } - } - - for (PropertyDefinition definition : definitions) { - Property property = propertiesByName.get(definition.name()); - boolean missing = property == null - || property.value() == null - || (property.value().isBlank()); - - if (missing) { - if (definition.required()) { - violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); - } - continue; - } + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null + || (property.value().isBlank()); - validatePropertyValue(definition, property.value()) - .forEach(violations::add); + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); } - } - + continue; + } - private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { - if (!(rawValue instanceof String stringValue)) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); - } + validatePropertyValue(definition, property.value()).forEach(violations::add); + } + } - if (rules == null) { - return List.of(); - } + private List validateStringPropertyValue(String propertyName, Object rawValue, + PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } - var violations = new ArrayList(); + if (rules == null) { + return List.of(); + } - if (rules.minLength() != null && stringValue.length() < rules.minLength()) { - violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); - } - if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { - violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); - } - if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { - violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); - } - if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { - violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); - } - if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { - violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); - } + var violations = new ArrayList(); - return List.copyOf(violations); + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile) + .matcher(stringValue).matches()) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() && rules.enumValues().stream() + .noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } - private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; switch (rawValue) { case Number number -> parsedValue = new BigDecimal(number.toString()); @@ -165,21 +183,21 @@ private List validateNumberPropertyValue(String propertyName, Object raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, Object rawValue) { - if (rawValue instanceof Boolean) { - return List.of(); - } - if (rawValue instanceof String string - && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { - return List.of(); - } - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); } - - private boolean matchesFormat(PropertyFormat format, String value) { - return switch (format) { - case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); - case URL -> URL_PATTERN.matcher(value).matches(); - }; + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { + return List.of(); } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java index 02a8138..9f08160 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java @@ -24,20 +24,21 @@ @AllArgsConstructor public class RelationService { - private final RelationRepositoryPort relationRepository; - - /// Finds all incoming relationships where specified entities are targets. - /// - /// **Contract:** Returns relationship summaries for dependency analysis and - /// impact assessment. Useful for understanding entity interconnections before - /// deletion or modification operations. - /// - /// @param targetEntityIdentifiers business identifiers of entities to analyze - /// @return relationship summaries showing incoming connections to - /// target entities - public List findRelationsSummariesByTargetEntityIdentifiers( - List targetEntityIdentifiers) { - return relationRepository.findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); - } + private final RelationRepositoryPort relationRepository; + + /// Finds all incoming relationships where specified entities are targets. + /// + /// **Contract:** Returns relationship summaries for dependency analysis and + /// impact assessment. Useful for understanding entity interconnections before + /// deletion or modification operations. + /// + /// @param targetEntityIdentifiers business identifiers of entities to analyze + /// @return relationship summaries showing incoming connections to + /// target entities + public List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers) { + return relationRepository + .findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java index 87a6caa..45408d7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java @@ -1,12 +1,14 @@ package com.decathlon.idp_core.domain.service.relation; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; + import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; + import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.model.entity.EntitySummary; @@ -15,6 +17,7 @@ import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity.Violations; + import lombok.RequiredArgsConstructor; /// Domain service validating entity relations against template relation definitions. @@ -22,84 +25,89 @@ @RequiredArgsConstructor public class RelationValidationService { - private final EntityRepositoryPort entityRepository; - - /// Validates entity relations against the template's relation definitions, enforcing required relations and cardinality constraints. - /// @param template the entity template whose relation definitions are used for validation - /// @param providedRelations the actual relations provided in the entity to validate - /// @param violations the accumulator for any validation violations found during the process - public void validateRelationsAgainstTemplate(EntityTemplate template, - List providedRelations, - Violations violations) { - - List definitions = template.relationsDefinitions() != null ? template.relationsDefinitions() : List.of(); - List relations = providedRelations != null ? providedRelations : List.of(); - - Map definitionsByName = definitions.stream() - .filter(def -> def.name() != null) - .collect(Collectors.toMap(RelationDefinition::name, def -> def, - (existing, replacement) -> existing)); - - Map relationsByName = relations.stream() - .filter(rel -> rel.name() != null) - .collect(Collectors.toMap(Relation::name, rel -> rel, - (existing, replacement) -> existing)); - - for (Relation relation : relations) { - if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { - violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); - } else { - validateRelationTargetEntityExistence(relation, violations); - } - } - - for (RelationDefinition definition : definitions) { - Relation relation = relationsByName.get(definition.name()); - List validTargets = extractValidTargetIdentifiers(relation); - - if (definition.required() && validTargets.isEmpty()) { - violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); - } - - if (relation != null && !definition.toMany() && validTargets.size() > 1) { - violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); - } - } + private final EntityRepositoryPort entityRepository; + + /// Validates entity relations against the template's relation definitions, + /// enforcing required relations and cardinality constraints. + /// @param template the entity template whose relation definitions are used for + /// validation + /// @param providedRelations the actual relations provided in the entity to + /// validate + /// @param violations the accumulator for any validation violations found during + /// the process + public void validateRelationsAgainstTemplate(EntityTemplate template, + List providedRelations, Violations violations) { + + List definitions = template.relationsDefinitions() != null + ? template.relationsDefinitions() + : List.of(); + List relations = providedRelations != null ? providedRelations : List.of(); + + Map definitionsByName = definitions.stream() + .filter(def -> def.name() != null).collect(Collectors.toMap(RelationDefinition::name, + def -> def, (existing, replacement) -> existing)); + + Map relationsByName = relations.stream().filter(rel -> rel.name() != null) + .collect(Collectors.toMap(Relation::name, rel -> rel, (existing, replacement) -> existing)); + + for (Relation relation : relations) { + if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { + violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); + } else { + validateRelationTargetEntityExistence(relation, violations); + } } - /// Validates that all target entity identifiers in the relation actually exist in the database. - /// - /// @param relation the relation whose target entity identifiers are to be validated - /// @param violations the accumulator for any validation violations found during the process - private void validateRelationTargetEntityExistence(Relation relation, Violations violations) { - List targetIdentifiers = extractValidTargetIdentifiers(relation); - - if (targetIdentifiers.isEmpty()) { - return; - } - - var existingEntities = entityRepository.findByIdentifierIn(targetIdentifiers); - Set existingIdentifiers = existingEntities.stream() - .map(EntitySummary::identifier) - .collect(Collectors.toSet()); - - for (String identifier : targetIdentifiers) { - if (!existingIdentifiers.contains(identifier)) { - violations.add(RELATION_TARGET_ENTITY_NOT_FOUND, relation.name(), identifier); - } - } + for (RelationDefinition definition : definitions) { + Relation relation = relationsByName.get(definition.name()); + List validTargets = extractValidTargetIdentifiers(relation); + + if (definition.required() && validTargets.isEmpty()) { + violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); + } + + if (relation != null && !definition.toMany() && validTargets.size() > 1) { + violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); + } + } + } + + /// Validates that all target entity identifiers in the relation actually exist + /// in the database. + /// + /// @param relation the relation whose target entity identifiers are to be + /// validated + /// @param violations the accumulator for any validation violations found during + /// the process + private void validateRelationTargetEntityExistence(Relation relation, Violations violations) { + List targetIdentifiers = extractValidTargetIdentifiers(relation); + + if (targetIdentifiers.isEmpty()) { + return; } - /// Extracts non-null, non-blank target entity identifiers from the relation, returning an empty list if the relation or its target identifiers are null. - /// - /// @param relation the relation from which to extract target entity identifiers - /// @return a list of valid target entity identifiers; empty if none are valid or if the relation is null - private List extractValidTargetIdentifiers(Relation relation) { - if (relation == null || relation.targetEntityIdentifiers() == null) { - return List.of(); - } - return relation.targetEntityIdentifiers().stream() - .filter(id -> id != null && !id.isBlank()) - .toList(); + var existingEntities = entityRepository.findByIdentifierIn(targetIdentifiers); + Set existingIdentifiers = existingEntities.stream().map(EntitySummary::identifier) + .collect(Collectors.toSet()); + + for (String identifier : targetIdentifiers) { + if (!existingIdentifiers.contains(identifier)) { + violations.add(RELATION_TARGET_ENTITY_NOT_FOUND, relation.name(), identifier); + } + } + } + + /// Extracts non-null, non-blank target entity identifiers from the relation, + /// returning an empty list if the relation or its target identifiers are null. + /// + /// @param relation the relation from which to extract target entity identifiers + /// @return a list of valid target entity identifiers; empty if none are valid + /// or if the relation is null + private List extractValidTargetIdentifiers(Relation relation) { + if (relation == null || relation.targetEntityIdentifiers() == null) { + return List.of(); } + return relation.targetEntityIdentifiers().stream().filter(id -> id != null && !id.isBlank()) + .toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 6109722..cfbca72 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -6,12 +6,11 @@ /// Type-safe CORS configuration properties bound from `spring.web.cors`. @ConfigurationProperties(prefix = "spring.web.cors") -public record CorsProperties( - List allowedOrigins, - List allowedOriginPatterns -) { - public CorsProperties { - allowedOrigins = allowedOrigins != null ? List.copyOf(allowedOrigins) : List.of(); - allowedOriginPatterns = allowedOriginPatterns != null ? List.copyOf(allowedOriginPatterns) : List.of(); - } +public record CorsProperties(List allowedOrigins, List allowedOriginPatterns) { + public CorsProperties { + allowedOrigins = allowedOrigins != null ? List.copyOf(allowedOrigins) : List.of(); + allowedOriginPatterns = allowedOriginPatterns != null + ? List.copyOf(allowedOriginPatterns) + : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index ac65335..0ce8107 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -5,9 +5,10 @@ /// Centralized OpenAPI documentation constants for consistent API descriptions. /// -/// **Documentation standardization rationale:** Maintains consistency across all API -/// endpoints by centralizing descriptions, response messages, and field documentation. -/// Prevents duplication and ensures uniform language throughout the API. +/// **Documentation standardization rationale:** Maintains consistency across all +/// API endpoints by centralizing descriptions, response messages, and field +/// documentation. Prevents duplication and ensures uniform language throughout +/// the API. /// /// **Organization strategy:** /// - HTTP status codes and standard responses @@ -15,146 +16,165 @@ /// - Schema and field descriptions for comprehensive API documentation /// - Pagination parameter descriptions for consistent query interfaces /// -/// **Maintenance benefits:** Single point of truth for API documentation strings, -/// enabling easy updates and internationalization if needed in the future. +/// **Maintenance benefits:** Single point of truth for API documentation +/// strings, enabling easy updates and internationalization if needed in +/// the future. @NoArgsConstructor(access = AccessLevel.PRIVATE) public class SwaggerDescription { - /// HTTP response status codes for OpenAPI documentation - public static final String OK_CODE = "200"; - public static final String CREATED_CODE = "201"; - public static final String NO_CONTENT_CODE = "204"; - public static final String PARTIAL_CONTENT_CODE = "206"; - public static final String BAD_REQUEST_CODE = "400"; - public static final String UNAUTHORIZED_CODE = "401"; - public static final String FORBIDDEN_CODE = "403"; - public static final String NOT_FOUND_CODE = "404"; - public static final String CONFLICT_CODE = "409"; - public static final String SERVICE_UNAVAILABLE_CODE = "503"; - public static final String INTERNAL_SERVER_ERROR_CODE = "500"; - - /// Entity Template API endpoint constants - public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; - public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; - - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; - - public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; - public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; - public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; - public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; - - public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; - public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; - - /// Entity API endpoint constants - public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; - public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; - - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; - - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; - - public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; - public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; - public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity"; - public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; - - - /// API response description constants - public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; - public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; - public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; - public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; - public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; - public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; - public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; - public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; - public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; - public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; - public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; - public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; - public static final String RESPONSE_ENTITY_FOUND = "Entity found"; - public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; - public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; - public static final String RESPONSE_ENTITY_UPDATED = "Entity updated successfully"; - public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; - public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; - public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; - public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; - - - // --- Schema (class) descriptions --- - public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; - public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; - public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; - public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; - public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; - public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; - public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; - public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; - public static final String SCHEMA_ENTITY_CREATE_IN = "Input DTO for creating an entity"; - public static final String SCHEMA_ENTITY_UPDATE_IN = "Input DTO for updating an entity"; - public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; - - // --- Field descriptions (shared) --- - public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; - public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; - public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; - public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; - public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; - public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; - - public static final String FIELD_ENTITY_NAME = "Name of the entity"; - public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; - public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; - public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; - public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; - public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; - - public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; - public static final String FIELD_PROPERTY_NAME = "Property name"; - public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; - public static final String FIELD_PROPERTY_TYPE = "Property data type"; - public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; - public static final String FIELD_PROPERTY_RULES = "Property validation rules"; - - public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; - public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; - public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; - public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; - public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; - public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; - public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; - public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; - public static final String FIELD_CREATED_AT = "Creation timestamp"; - public static final String FIELD_UPDATED_AT = "Last update timestamp"; - - public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; - public static final String FIELD_RELATION_NAME = "Name of the relation"; - public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; - public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; - public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; - - // --- Pagination and sorting parameter descriptions --- - public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; - public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - public static final String PARAM_QUERY_DESCRIPTION = """ - Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. - """; - public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + /// HTTP response status codes for OpenAPI documentation + public static final String OK_CODE = "200"; + public static final String CREATED_CODE = "201"; + public static final String NO_CONTENT_CODE = "204"; + public static final String PARTIAL_CONTENT_CODE = "206"; + public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; + public static final String NOT_FOUND_CODE = "404"; + public static final String CONFLICT_CODE = "409"; + public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; + + /// Entity Template API endpoint constants + public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; + public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; + + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; + + public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; + public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; + public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; + public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; + + public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; + public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; + + /// Entity API endpoint constants + public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; + public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; + + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; + + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; + + public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; + public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; + public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity"; + public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; + + /// API response description constants + public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; + public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; + public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; + public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; + public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; + public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; + public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; + public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; + public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; + public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; + public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; + public static final String RESPONSE_ENTITY_FOUND = "Entity found"; + public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; + public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; + public static final String RESPONSE_ENTITY_UPDATED = "Entity updated successfully"; + public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; + + // --- Schema (class) descriptions --- + public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; + public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; + public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; + public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; + public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; + public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; + public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_CREATE_IN = "Input DTO for creating an entity"; + public static final String SCHEMA_ENTITY_UPDATE_IN = "Input DTO for updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; + + // --- Field descriptions (shared) --- + public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; + public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; + public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; + public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; + public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; + public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; + public static final String FIELD_PROPERTY_NAME = "Property name"; + public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; + public static final String FIELD_PROPERTY_TYPE = "Property data type"; + public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; + public static final String FIELD_PROPERTY_RULES = "Property validation rules"; + + public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; + public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; + public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; + public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; + public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; + public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; + public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; + public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; + public static final String FIELD_CREATED_AT = "Creation timestamp"; + public static final String FIELD_UPDATED_AT = "Last update timestamp"; + + public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; + public static final String FIELD_RELATION_NAME = "Name of the relation"; + public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; + public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; + public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; + + // --- Pagination and sorting parameter descriptions --- + public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + public static final String PARAM_QUERY_DESCRIPTION = """ + Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. + """; + public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + + // --- Entity Graph (flat nodes & edges) descriptions --- + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + 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 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"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 7d59362..f46ee69 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -36,7 +36,9 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -44,12 +46,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.PutMapping; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; @@ -63,6 +65,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -70,8 +73,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; /// REST API adapter providing entity management endpoints. /// @@ -88,120 +90,143 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; - private final EntityQueryParserService entityQueryParserService; + private final EntityService entityService; + private final EntityDtoOutMapper entityDtoOutMapper; + private final EntityDtoInMapper entityDtoInMapper; + private final EntityQueryParserService entityQueryParserService; - /// Returns paginated entities filtered by template with HTTP pagination support. - /// - /// **API contract:** Provides paginated entity listings for template-specific views. - /// Supports standard REST pagination parameters and an optional `q` filter query. - /// Template validation is handled by the domain service layer. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control - /// @param templateIdentifier template filter for entity scope limitation - /// @param q optional filter query string (e.g. `name:API;property.language=JAVA`) - /// @return paginated entity DTOs matching the template and optional filter - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); - EntityFilter filter = entityQueryParserService.parse(q); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, filter); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. + /// Supports standard REST pagination parameters and an optional `q` filter + /// query. + /// Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @param q optional filter query string (e.g. + /// `name:API;property.language=JAVA`) + /// @return paginated entity DTOs matching the template and optional filter + @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityQueryParserService.parse(q); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, filter); + return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + } - /// Retrieves a single entity by template and entity identifiers. - /// - /// **API contract:** Provides specific entity lookup using compound identifier pattern. - /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. - /// - /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context - /// @return entity DTO with full property and relationship data - @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @GetMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut getEntity( - @PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); - return entityDtoOutMapper.fromEntity(entity); - } + /// Retrieves a single entity by template and entity identifiers. + /// + /// **API contract:** Provides specific entity lookup using compound identifier + /// pattern. + /// Returns HTTP 404 if either template or entity doesn't exist, maintaining + /// REST semantics. + /// + /// @param templateIdentifier business template identifier for entity scope + /// @param entityIdentifier unique business identifier within template context + /// @return entity DTO with full property and relationship data + @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut getEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier) { + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier); + return entityDtoOutMapper.fromEntity(entity); + } - /// Creates a new entity for the specified template with validation. - /// - /// **API contract:** Accepts entity creation payload and returns created entity with - /// generated identifiers. Validates entity structure against template constraints - /// and returns HTTP 201 on success, HTTP 400 for validation errors. - /// - /// @param templateIdentifier target template identifier for entity creation context - /// @param entityCreateDtoIn entity creation payload with properties and relationships - /// @return created entity DTO with server-generated identifiers - @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PostMapping("/{templateIdentifier}") - @ResponseStatus(CREATED) - public EntityDtoOut createEntity( - @NotBlank @PathVariable String templateIdentifier, - @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { + /// Creates a new entity for the specified template with validation. + /// + /// **API contract:** Accepts entity creation payload and returns created entity + /// with + /// generated identifiers. Validates entity structure against template + /// constraints + /// and returns HTTP 201 on success, HTTP 400 for validation errors. + /// + /// @param templateIdentifier target template identifier for entity creation + /// context + /// @param entityCreateDtoIn entity creation payload with properties and + /// relationships + /// @return created entity DTO with server-generated identifiers + @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/{templateIdentifier}") + @ResponseStatus(CREATED) + public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { - Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, + templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } - /// Updates an existing entity for the specified template. - /// - /// **API contract:** Accepts entity update payload and returns updated entity. Validates - /// that the entity exists and that the update payload conforms to template constraints. Returns HTTP 200 on success, HTTP 400 for validation errors, HTTP 404 if entity doesn't exist. - /// - /// @param templateIdentifier target template identifier for entity update context - /// @param entityIdentifier unique business identifier of the entity to update - /// @param entityUpdateDtoIn entity update payload with properties and relationships to apply - /// @return updated entity DTO reflecting persisted changes - @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PutMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut updateEntity( - @NotBlank @PathVariable String templateIdentifier, - @NotBlank @PathVariable String entityIdentifier, - @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { + /// Updates an existing entity for the specified template. + /// + /// **API contract:** Accepts entity update payload and returns updated entity. + /// Validates + /// that the entity exists and that the update payload conforms to template + /// constraints. Returns HTTP 200 on success, HTTP 400 for validation errors, + /// HTTP 404 if entity doesn't exist. + /// + /// @param templateIdentifier target template identifier for entity update + /// context + /// @param entityIdentifier unique business identifier of the entity to update + /// @param entityUpdateDtoIn entity update payload with properties and + /// relationships to apply + /// @return updated entity DTO reflecting persisted changes + @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PutMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier, + @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { - Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, templateIdentifier, entityIdentifier); - Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); - return entityDtoOutMapper.fromEntity(updatedEntity); - } + Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, + templateIdentifier, entityIdentifier); + Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); + return entityDtoOutMapper.fromEntity(updatedEntity); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java index 12f9294..7215513 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java @@ -4,17 +4,18 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_CREATE_IN; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /// Input DTO for creating a new entity within a template scope. /// @@ -29,11 +30,11 @@ @Schema(description = SCHEMA_ENTITY_CREATE_IN) public class EntityCreateDtoIn { - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") - private String identifier; + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") + private String identifier; - @Valid - @JsonUnwrapped - private EntityDtoInCommonFields entityDtoInCommonFields; + @Valid + @JsonUnwrapped + private EntityDtoInCommonFields entityDtoInCommonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java index 7d68f36..61031e3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java @@ -14,17 +14,18 @@ import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /// Input DTO for common fields of an entity creation or update request. /// @@ -39,35 +40,35 @@ @Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoInCommonFields { - @NotBlank(message = ENTITY_NAME_MANDATORY) - @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") - private String name; + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") + private String name; - @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") + private Map properties; - @Valid - @Schema(description = FIELD_ENTITY_RELATIONS) - private List relations; + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) + private List relations; - /// Input DTO for an entity relation instance. - /// - /// **Infrastructure validation:** Validates relation name presence and target - /// identifiers at the API boundary before domain-level schema checks. - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonNaming(SnakeCaseStrategy.class) - @Schema(description = SCHEMA_ENTITY_RELATION_IN) - public static class RelationDtoIn { + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) + public static class RelationDtoIn { - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") - private String name; + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") + private String name; - @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) - @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") - private List targetEntityIdentifiers; - } + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") + private List targetEntityIdentifiers; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java index dc259e3..54b973b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java @@ -2,16 +2,17 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_UPDATE_IN; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.validation.Valid; import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /// Input DTO for updating an entity within a template scope. /// @@ -26,8 +27,8 @@ @Schema(description = SCHEMA_ENTITY_UPDATE_IN) public class EntityUpdateDtoIn { - @Valid - @JsonUnwrapped - private EntityDtoInCommonFields entityDtoInCommonFields; + @Valid + @JsonUnwrapped + private EntityDtoInCommonFields entityDtoInCommonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 64f5a48..75929d6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -1,15 +1,15 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; +import static org.springframework.http.HttpStatus.NOT_FOUND; + import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; @@ -25,19 +26,19 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import static org.springframework.http.HttpStatus.NOT_FOUND; - /// Global exception handler providing centralized error handling for all API endpoints. /// /// **Infrastructure error handling strategy:** Intercepts domain and validation exceptions @@ -56,331 +57,353 @@ @ControllerAdvice public class ApiExceptionHandler { - private ApiExceptionHandler() { - } - - /// Handles domain exception when entity templates are not found. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 status - /// with business-meaningful error message for API consumers. - @ExceptionHandler(EntityTemplateNotFoundException.class) - public ResponseEntity handleTemplateNotFoundException(EntityTemplateNotFoundException ex) { - log.warn("Template not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); - } - - /// Handles domain exception for malformed filter query strings. - /// - /// **HTTP mapping:** Maps domain [InvalidQueryDslException] to HTTP 400 Bad Request - /// so API consumers receive clear feedback about invalid `q` parameter syntax. - @ExceptionHandler(InvalidQueryDslException.class) - public ResponseEntity handleInvalidQueryDslException(InvalidQueryDslException ex) { - log.warn("Invalid filter query: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when entity templates already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate identifiers. - @ExceptionHandler(EntityTemplateAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateAlreadyExistsException( - EntityTemplateAlreadyExistsException ex) { - log.warn("Entity template already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when entity template names already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate template names. - @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateNameAlreadyExistsException( - EntityTemplateNameAlreadyExistsException ex) { - log.warn("Entity template name already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when attempting to change an entity template identifier. - /// - /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException to HTTP 400 - /// status indicating validation error for immutable identifier field. - @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) - public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( - EntityTemplateIdentifierCannotChangeException ex) { - log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception for wrong entity template property rules. - /// - /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 - /// status indicating validation error for wrong property rules. - @ExceptionHandler(PropertyDefinitionRulesConflictException.class) - public ResponseEntity handleWrongPropertyRulesException( - PropertyDefinitionRulesConflictException ex) { - log.warn("Wrong Entity template property rules: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception when property names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate property names. - @ExceptionHandler(PropertyNameAlreadyExistsException.class) - public ResponseEntity handlePropertyNameAlreadyExistsException( - PropertyNameAlreadyExistsException ex) { - log.warn("Duplicate property name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when relation names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate relation names. - @ExceptionHandler(RelationNameAlreadyExistsException.class) - public ResponseEntity handleRelationNameAlreadyExistsException( - RelationNameAlreadyExistsException ex) { - log.warn("Duplicate relation name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when a relation references a non-existent target template. - /// - /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target template. - @ExceptionHandler(TargetTemplateNotFoundException.class) - public ResponseEntity handleTargetTemplateNotFoundException( - TargetTemplateNotFoundException ex) { - log.warn("Target template not found: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when type changes are attempted. - /// - /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 - /// status indicating validation error for type changes. - @ExceptionHandler(PropertyTypeChangeException.class) - public ResponseEntity handleTypeChangeException( - PropertyTypeChangeException ex) { - log.warn("Type change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when relation target template changes are attempted. - /// - /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP 400 - /// status indicating validation error for immutable target template field. - @ExceptionHandler(RelationTargetTemplateChangeException.class) - public ResponseEntity handleRelationTargetTemplateChangeException( - RelationTargetTemplateChangeException ex) { - log.warn("Relation target template change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when a relation's target template identifier is the template itself. - /// - /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP 400 - /// status indicating validation error for self-referential relations. - @ExceptionHandler(RelationCannotTargetItselfException.class) - public ResponseEntity handleRelationCannotTargetItselfException( - RelationCannotTargetItselfException ex) { - log.warn("Relation self-reference error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles validation exceptions from Spring MVC handler method parameters. - /// - /// **Error aggregation:** Combines multiple validation error messages into a single - /// user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(HandlerMethodValidationException.class) - public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { - log.warn("Handler method validation error: {}", ex.getMessage()); - String errorMessage = ex.getAllErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); - } - - /// Handles domain exception when entities already exist. - /// - /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate entities. - @ExceptionHandler(EntityAlreadyExistsException.class) - public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { - log.warn("Entity already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when entity validation fails. - /// - /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated - /// validation error messages for client correction. - @ExceptionHandler(EntityValidationException.class) - public ResponseEntity handleEntityValidationException(EntityValidationException ex) { - log.warn("Entity validation failed: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles Bean Validation constraint violations from domain model validation. - /// - /// **Error aggregation:** Combines multiple constraint violation messages into - /// single user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { - log.warn("Validation constraint violation: {}", ex.getMessage()); - - String errorMessage = ex.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + private ApiExceptionHandler() { + } + + /// Handles domain exception when entity templates are not found. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 + /// status + /// with business-meaningful error message for API consumers. + @ExceptionHandler(EntityTemplateNotFoundException.class) + public ResponseEntity handleTemplateNotFoundException( + EntityTemplateNotFoundException ex) { + log.warn("Template not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles domain exception for malformed filter query strings. + /// + /// **HTTP mapping:** Maps domain [InvalidQueryDslException] to HTTP 400 Bad + /// Request + /// so API consumers receive clear feedback about invalid `q` parameter syntax. + @ExceptionHandler(InvalidQueryDslException.class) + public ResponseEntity handleInvalidQueryDslException(InvalidQueryDslException ex) { + log.warn("Invalid filter query: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when entity templates already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP + /// 409 + /// status indicating business rule conflict for duplicate identifiers. + @ExceptionHandler(EntityTemplateAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateAlreadyExistsException( + EntityTemplateAlreadyExistsException ex) { + log.warn("Entity template already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity template names already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to + /// HTTP 409 + /// status indicating business rule conflict for duplicate template names. + @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateNameAlreadyExistsException( + EntityTemplateNameAlreadyExistsException ex) { + log.warn("Entity template name already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when attempting to change an entity template + /// identifier. + /// + /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException + /// to HTTP 400 + /// status indicating validation error for immutable identifier field. + @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) + public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + EntityTemplateIdentifierCannotChangeException ex) { + log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception for wrong entity template property rules. + /// + /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to + /// HTTP 400 + /// status indicating validation error for wrong property rules. + @ExceptionHandler(PropertyDefinitionRulesConflictException.class) + public ResponseEntity handleWrongPropertyRulesException( + PropertyDefinitionRulesConflictException ex) { + log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception when property names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate property names. + @ExceptionHandler(PropertyNameAlreadyExistsException.class) + public ResponseEntity handlePropertyNameAlreadyExistsException( + PropertyNameAlreadyExistsException ex) { + log.warn("Duplicate property name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate relation names. + @ExceptionHandler(RelationNameAlreadyExistsException.class) + public ResponseEntity handleRelationNameAlreadyExistsException( + RelationNameAlreadyExistsException ex) { + log.warn("Duplicate relation name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation references a non-existent target + /// template. + /// + /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 + /// status indicating validation error for missing target template. + @ExceptionHandler(TargetTemplateNotFoundException.class) + public ResponseEntity handleTargetTemplateNotFoundException( + TargetTemplateNotFoundException ex) { + log.warn("Target template not found: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when type changes are attempted. + /// + /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 + /// status indicating validation error for type changes. + @ExceptionHandler(PropertyTypeChangeException.class) + public ResponseEntity handleTypeChangeException(PropertyTypeChangeException ex) { + log.warn("Type change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation target template changes are + /// attempted. + /// + /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP + /// 400 + /// status indicating validation error for immutable target template field. + @ExceptionHandler(RelationTargetTemplateChangeException.class) + public ResponseEntity handleRelationTargetTemplateChangeException( + RelationTargetTemplateChangeException ex) { + log.warn("Relation target template change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation's target template identifier is the + /// template itself. + /// + /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP + /// 400 + /// status indicating validation error for self-referential relations. + @ExceptionHandler(RelationCannotTargetItselfException.class) + public ResponseEntity handleRelationCannotTargetItselfException( + RelationCannotTargetItselfException ex) { + log.warn("Relation self-reference error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a + /// single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException( + HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException( + EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status + /// with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException( + EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles Bean Validation constraint violations from domain model validation. + /// + /// **Error aggregation:** Combines multiple constraint violation messages into + /// single user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Validation constraint violation: {}", ex.getMessage()); + + String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles Spring MVC request body validation failures. + /// + /// **Field-level errors:** Extracts and aggregates field validation errors from + /// request body binding into comprehensive HTTP 400 error response. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + log.warn("Method argument validation error: {}", ex.getMessage()); + + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles JSON parsing and deserialization errors from request bodies. + /// + /// **User-friendly parsing:** Converts technical JSON parsing errors into + /// readable messages, especially for enum validation and format issues. + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex) { + log.warn("HTTP message not readable: {}", ex.getMessage()); + + String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities are not found. + /// + /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status + /// with specific entity context for API consumers. + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + private String parseHttpMessageNotReadableError(String originalMessage) { + if (originalMessage == null) { + return "Invalid request body format"; } - /// Handles Spring MVC request body validation failures. - /// - /// **Field-level errors:** Extracts and aggregates field validation errors from - /// request body binding into comprehensive HTTP 400 error response. - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { - log.warn("Method argument validation error: {}", ex.getMessage()); - - String errorMessage = ex.getBindingResult().getFieldErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + if (originalMessage.contains("Cannot deserialize value")) { + return parseDeserializationError(originalMessage); + } else if (originalMessage.contains("Required request body is missing")) { + return "Request body is required"; + } else if (originalMessage.contains("JSON parse error")) { + return "Invalid JSON format in request body"; } - /// Handles JSON parsing and deserialization errors from request bodies. - /// - /// **User-friendly parsing:** Converts technical JSON parsing errors into - /// readable messages, especially for enum validation and format issues. - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { - log.warn("HTTP message not readable: {}", ex.getMessage()); + return "Invalid request body format"; + } - String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + private String parseDeserializationError(String originalMessage) { + if (originalMessage.contains("not one of the values accepted for Enum class")) { + return parseEnumDeserializationError(originalMessage); } + return parseTypeDeserializationError(originalMessage); + } + private String parseTypeDeserializationError(String originalMessage) { + String targetType = extractTargetType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - /// Handles domain exception when entities are not found. - /// - /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status - /// with specific entity context for API consumers. - @ExceptionHandler(EntityNotFoundException.class) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + if (!targetType.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property, expected " + targetType; + } else if (!targetType.isEmpty()) { + return "Invalid type: expected " + targetType; } - private String parseHttpMessageNotReadableError(String originalMessage) { - if (originalMessage == null) { - return "Invalid request body format"; - } - - if (originalMessage.contains("Cannot deserialize value")) { - return parseDeserializationError(originalMessage); - } else if (originalMessage.contains("Required request body is missing")) { - return "Request body is required"; - } else if (originalMessage.contains("JSON parse error")) { - return "Invalid JSON format in request body"; - } - - return "Invalid request body format"; + return "Cannot deserialize request body property"; + } + + private String extractTargetType(String message) { + Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); + Matcher matcher = typePattern.matcher(message); + if (matcher.find()) { + String fullType = matcher.group(1); + return fullType.substring(fullType.lastIndexOf('.') + 1); } - - private String parseDeserializationError(String originalMessage) { - if (originalMessage.contains("not one of the values accepted for Enum class")) { - return parseEnumDeserializationError(originalMessage); - } - return parseTypeDeserializationError(originalMessage); + return ""; + } + + private String extractInvalidValueFromString(String message) { + Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); + Matcher matcher = valuePattern.matcher(message); + if (matcher.find()) { + return matcher.group(1); } + return ""; + } - private String parseTypeDeserializationError(String originalMessage) { - String targetType = extractTargetType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!targetType.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property, expected " + targetType; - } else if (!targetType.isEmpty()) { - return "Invalid type: expected " + targetType; - } - return "Cannot deserialize request body property"; - } + private String parseEnumDeserializationError(String originalMessage) { + String enumTypeName = getPropertyNameFromEnumType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - private String extractTargetType(String message) { - Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); - Matcher matcher = typePattern.matcher(message); - if (matcher.find()) { - String fullType = matcher.group(1); - return fullType.substring(fullType.lastIndexOf('.') + 1); - } - return ""; + if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; + } else if (!enumTypeName.isEmpty()) { + return "Invalid value for property '" + enumTypeName + "'"; } + return "Invalid enum value in request body"; + } - private String extractInvalidValueFromString(String message) { - Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); - Matcher matcher = valuePattern.matcher(message); - if (matcher.find()) { - return matcher.group(1); - } - return ""; - } - - private String parseEnumDeserializationError(String originalMessage) { - String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; - } else if (!enumTypeName.isEmpty()) { - return "Invalid value for property '" + enumTypeName + "'"; - } - return "Invalid enum value in request body"; - } + private static final Map ENUM_TYPE_TO_PROPERTY = Map.of("PropertyType", "type", + "PropertyFormat", "format"); - private static final Map ENUM_TYPE_TO_PROPERTY = Map.of( - "PropertyType", "type", - "PropertyFormat", "format"); - - private static final Pattern ENUM_CLASS_PATTERN = Pattern.compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - - private String getPropertyNameFromEnumType(String message) { - Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); - if (matcher.find()) { - String enumType = matcher.group(1); - return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); - } - return ""; - } - - /// Handles all unexpected exceptions as safety fallback. - /// - /// **Security consideration:** Returns generic error message to prevent information - /// leakage while logging full exception details for internal debugging. - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - - String errorMessage = "An unexpected error occurred. Please try again later."; - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); - } - - private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { - return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); - } + private static final Pattern ENUM_CLASS_PATTERN = Pattern + .compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - @Getter - @AllArgsConstructor - @NoArgsConstructor(force = true) - public static class ErrorResponse { - private String error; - private String errorDescription; + private String getPropertyNameFromEnumType(String message) { + Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); + if (matcher.find()) { + String enumType = matcher.group(1); + return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); } + return ""; + } + + /// Handles all unexpected exceptions as safety fallback. + /// + /// **Security consideration:** Returns generic error message to prevent + /// information + /// leakage while logging full exception details for internal debugging. + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + + String errorMessage = "An unexpected error occurred. Please try again later."; + return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); + } + + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, + String errorMessage) { + return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor(force = true) + public static class ErrorResponse { + private String error; + private String errorDescription; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 814a8df..08e6059 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -30,56 +30,43 @@ @Component public class EntityDtoInMapper { - /// Converts an entity creation request DTO to a domain entity. - /// - /// @param entityCreateDtoIn the entity creation request payload - /// @param entityTemplateIdentifier the target template identifier - /// @return the mapped domain entity with audit fields populated - public Entity fromPostEntityDtoInToEntity(EntityCreateDtoIn entityCreateDtoIn, String entityTemplateIdentifier) { - return buildEntity( - entityCreateDtoIn.getEntityDtoInCommonFields(), - entityTemplateIdentifier, - entityCreateDtoIn.getIdentifier() - ); - } + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityCreateDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated + public Entity fromPostEntityDtoInToEntity(EntityCreateDtoIn entityCreateDtoIn, + String entityTemplateIdentifier) { + return buildEntity(entityCreateDtoIn.getEntityDtoInCommonFields(), entityTemplateIdentifier, + entityCreateDtoIn.getIdentifier()); + } - /// Converts an entity update request DTO to a domain entity. - /// - /// @param entityUpdateDtoIn the entity update request payload - /// @param entityTemplateIdentifier the target template identifier - /// @param entityIdentifier the target entity identifier from request path - /// @return the mapped domain entity with audit fields populated - public Entity fromPutEntityDtoInToEntity(EntityUpdateDtoIn entityUpdateDtoIn, - String entityTemplateIdentifier, - String entityIdentifier) { - return buildEntity( - entityUpdateDtoIn.getEntityDtoInCommonFields(), - entityTemplateIdentifier, - entityIdentifier - ); - } + /// Converts an entity update request DTO to a domain entity. + /// + /// @param entityUpdateDtoIn the entity update request payload + /// @param entityTemplateIdentifier the target template identifier + /// @param entityIdentifier the target entity identifier from request path + /// @return the mapped domain entity with audit fields populated + public Entity fromPutEntityDtoInToEntity(EntityUpdateDtoIn entityUpdateDtoIn, + String entityTemplateIdentifier, String entityIdentifier) { + return buildEntity(entityUpdateDtoIn.getEntityDtoInCommonFields(), entityTemplateIdentifier, + entityIdentifier); + } - /// Shared helper method to build the domain entity from common fields. - private Entity buildEntity(EntityDtoInCommonFields commonFields, String entityTemplateIdentifier, String identifier) { - List properties = commonFields.getProperties() == null - ? Collections.emptyList() - : commonFields.getProperties().entrySet().stream() - .map(entry -> new Property(null, entry.getKey(), entry.getValue())) - .toList(); + /// Shared helper method to build the domain entity from common fields. + private Entity buildEntity(EntityDtoInCommonFields commonFields, String entityTemplateIdentifier, + String identifier) { + List properties = commonFields.getProperties() == null + ? Collections.emptyList() + : commonFields.getProperties().entrySet().stream() + .map(entry -> new Property(null, entry.getKey(), entry.getValue())).toList(); - List relations = commonFields.getRelations() == null - ? Collections.emptyList() - : commonFields.getRelations().stream() - .map(relDto -> new Relation(null, relDto.getName(), null, relDto.getTargetEntityIdentifiers())) - .toList(); + List relations = commonFields.getRelations() == null + ? Collections.emptyList() + : commonFields.getRelations().stream().map(relDto -> new Relation(null, relDto.getName(), + null, relDto.getTargetEntityIdentifiers())).toList(); - return new Entity( - null, - entityTemplateIdentifier, - commonFields.getName(), - identifier, - properties, - relations - ); - } + return new Entity(null, entityTemplateIdentifier, commonFields.getName(), identifier, + properties, relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 5edf89b..80957ce 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -49,262 +49,259 @@ @RequiredArgsConstructor public class EntityDtoOutMapper { - private final EntityTemplateService entityTemplateService; - private final EntityService entityService; - private final RelationService relationService; + private final EntityTemplateService entityTemplateService; + private final EntityService entityService; + private final RelationService relationService; - /// Maps a single domain entity to API DTO using template-based conversion. - /// - /// **Infrastructure mapping:** Resolves entity template dynamically and performs - /// complete domain-to-DTO transformation including properties and relationships. - /// - /// @param entity domain entity to convert for API response - /// @return fully mapped entity DTO with resolved template metadata - public EntityDtoOut fromEntity(Entity entity) { - EntityTemplate entityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entity.templateIdentifier()); - return fromEntityUsingEntityTemplate(entity, entityTemplate); - } + /// Maps a single domain entity to API DTO using template-based conversion. + /// + /// **Infrastructure mapping:** Resolves entity template dynamically and + /// performs + /// complete domain-to-DTO transformation including properties and + /// relationships. + /// + /// @param entity domain entity to convert for API response + /// @return fully mapped entity DTO with resolved template metadata + public EntityDtoOut fromEntity(Entity entity) { + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + return fromEntityUsingEntityTemplate(entity, entityTemplate); + } - /// Maps paginated domain entities to API DTOs with optimized bulk operations. - /// - /// **Performance optimization:** Batches template resolution and relationship lookups - /// to minimize database queries. Builds summary maps for efficient relationship - /// resolution across the entire page. - /// - /// @param entities paginated domain entities from repository layer - /// @param entityTemplateIdentifier template identifier for batch template resolution - /// @return paginated API DTOs with complete relationship data - public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + /// Maps paginated domain entities to API DTOs with optimized bulk operations. + /// + /// **Performance optimization:** Batches template resolution and relationship + /// lookups + /// to minimize database queries. Builds summary maps for efficient relationship + /// resolution across the entire page. + /// + /// @param entities paginated domain entities from repository layer + /// @param entityTemplateIdentifier template identifier for batch template + /// resolution + /// @return paginated API DTOs with complete relationship data + public Page fromEntitiesPageToDtoPage(Page entities, + String entityTemplateIdentifier) { - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); + Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( + entities); + Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( + entities); - EntityTemplate pageEntityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, pageEntityTemplate, - pageEntitiesSummaries, relationTargetOwnershipsMap)); - } + EntityTemplate pageEntityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, + pageEntityTemplate, pageEntitiesSummaries, relationTargetOwnershipsMap)); + } - /// Maps a single entity to its DTO using the provided entity template. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { - Map props = mapPropertiesDto(entity, entityTemplate); + /// Maps a single entity to its DTO using the provided entity template. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + Map props = mapPropertiesDto(entity, entityTemplate); - List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); - Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap(allTargetIdentifiers); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaryMap); - Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( - entity); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relatedEntitiesByTargetSummaryMap); + List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); + Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap( + allTargetIdentifiers); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaryMap); + Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( + entity); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relatedEntitiesByTargetSummaryMap); - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); - } + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } - /// Maps a single entity to its DTO using pre-built summary and - /// relation-as-target maps. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + /// Maps a single entity to its DTO using pre-built summary and + /// relation-as-target maps. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, + EntityTemplate entityTemplate, Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { - Map props = mapPropertiesDto(entity, entityTemplate); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relationTargetOwnershipsMap); + Map props = mapPropertiesDto(entity, entityTemplate); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaries); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relationTargetOwnershipsMap); - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } + + /// Maps the properties of an entity to a map of property names to typed values, + /// using the entity template for type conversion. + /// Properties with a null value are excluded from the output. + /// + /// @param entity the entity whose properties to map + /// @param entityTemplate the template for property type mapping + /// @return a map of property names to typed values + private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { + if (entity.properties() == null) { + return Collections.emptyMap(); } - /// Maps the properties of an entity to a map of property names to typed values, - /// using the entity template for type conversion. - /// Properties with a null value are excluded from the output. - /// - /// @param entity the entity whose properties to map - /// @param entityTemplate the template for property type mapping - /// @return a map of property names to typed values - private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { - if (entity.properties() == null) { - return Collections.emptyMap(); - } + Map propertiesDefinitions = entityTemplate.propertiesDefinitions() + .stream().collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); - Map propertiesDefinitions = entityTemplate.propertiesDefinitions().stream() - .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); + return entity.properties().stream().filter(prop -> prop.value() != null) + .collect(Collectors.toMap(Property::name, + prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + } - return entity.properties().stream() - .filter(prop -> prop.value() != null) - .collect(Collectors.toMap( - Property::name, - prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + /// Converts a property value to its typed representation based on the property + /// definition. + /// + /// @param property the property to convert + /// @param definition the property definition for type information, may be null + /// @return the typed value, falling back to the raw string value + private Object convertPropertyValue(Property property, PropertyDefinition definition) { + String value = property.value(); + if (definition == null) { + return value; } - - /// Converts a property value to its typed representation based on the property definition. - /// - /// @param property the property to convert - /// @param definition the property definition for type information, may be null - /// @return the typed value, falling back to the raw string value - private Object convertPropertyValue(Property property, PropertyDefinition definition) { - String value = property.value(); - if (definition == null) { - return value; - } - PropertyType type = definition.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return value; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } + PropertyType type = definition.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(value); + } catch (NumberFormatException _) { return value; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(value); } + return value; + } - /// Maps the relations of an entity to a map of relation names to lists of target - /// entity summaries. - /// - /// @param entity the entity whose relations to map - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @return a map of relation names to lists of target entity summaries - private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { - return entity.relations() == null - ? Collections.emptyMap() - : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() - .map(relatedEntitiesSummaries::get) - .filter(Objects::nonNull), - Collectors.toList()))); - } - - /// Maps the relations-as-target for an entity to a map of relation names to - /// lists of source entity summaries. - /// - /// @param entity the entity whose relations-as-target to map - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return a map of relation names to lists of source entity summaries - private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { - List relationAsTargetSummaries = relationTargetOwnershipsMap.get(entity.identifier()); - if (relationAsTargetSummaries == null) { - return Collections.emptyMap(); - } + /// Maps the relations of an entity to a map of relation names to lists of + /// target + /// entity summaries. + /// + /// @param entity the entity whose relations to map + /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @return a map of relation names to lists of target entity summaries + private Map> mapRelationsDto(Entity entity, + Map relatedEntitiesSummaries) { + return entity.relations() == null + ? Collections.emptyMap() + : entity.relations().stream().collect(Collectors.groupingBy(Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .map(relatedEntitiesSummaries::get).filter(Objects::nonNull), + Collectors.toList()))); + } - return relationAsTargetSummaries.stream() - .collect(Collectors.groupingBy( - RelationAsTargetSummary::relationName, - Collectors.mapping( - r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), - Collectors.toList()))); + /// Maps the relations-as-target for an entity to a map of relation names to + /// lists of source entity summaries. + /// + /// @param entity the entity whose relations-as-target to map + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return a map of relation names to lists of source entity summaries + private Map> mapRelationsAsTargetDto(Entity entity, + Map> relationTargetOwnershipsMap) { + List relationAsTargetSummaries = relationTargetOwnershipsMap + .get(entity.identifier()); + if (relationAsTargetSummaries == null) { + return Collections.emptyMap(); } - /// Builds a map of relation target ownerships for a page of entities, grouping - /// by target entity identifier. - /// - /// @param entitiesPage the page of entities to analyze - /// @return a map from target entity identifier to list of relation-as-target summaries - private Map> buildRelationsAsTargetSummaryMapByPage( - Page entitiesPage) { - if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { - return Collections.emptyMap(); - } - List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) - .filter(Objects::nonNull).toList(); - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); - } + return relationAsTargetSummaries.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::relationName, + Collectors.mapping( + r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), + Collectors.toList()))); + } - /// Builds a map of relation target ownerships for a single entity, grouping by - /// target entity identifier. - /// - /// @param entity the entity to analyze - /// @return a map from target entity identifier to list of relation-as-target summaries - private Map> buildRelationsAsTargetSummaryMapByEntity(Entity entity) { - if (entity == null || entity.identifier() == null) { - return Collections.emptyMap(); - } - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + /// Builds a map of relation target ownerships for a page of entities, grouping + /// by target entity identifier. + /// + /// @param entitiesPage the page of entities to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByPage( + Page entitiesPage) { + if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { + return Collections.emptyMap(); } + List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) + .filter(Objects::nonNull).toList(); + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } - /// Gets all unique target entity identifiers from the relations of a single entity. - /// - /// @param entity the entity to analyze - /// @return a list of unique target entity identifiers - private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { - return entity.relations() == null - ? Collections.emptyList() - : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + /// Builds a map of relation target ownerships for a single entity, grouping by + /// target entity identifier. + /// + /// @param entity the entity to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByEntity( + Entity entity) { + if (entity == null || entity.identifier() == null) { + return Collections.emptyMap(); } + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } - /// Gets all unique target entity identifiers from the relations of all entities in a page. - /// - /// @param entities the page of entities to analyze - /// @return a list of unique target entity identifiers - private List getUniqueTargetIdentifiersInPage(Page entities) { - return new ArrayList<>(entities.stream() - .flatMap(entity -> entity.relations() == null - ? Stream.empty() - : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) - .collect(Collectors.toSet())); - } + /// Gets all unique target entity identifiers from the relations of a single + /// entity. + /// + /// @param entity the entity to analyze + /// @return a list of unique target entity identifiers + private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { + return entity.relations() == null + ? Collections.emptyList() + : new ArrayList<>(entity.relations().stream() + .flatMap(rel -> rel.targetEntityIdentifiers().stream()).collect(Collectors.toSet())); + } - /// Builds a map of entity summaries for all unique target identifiers in a page of entities. - /// - /// @param entities the page of entities - /// @return a map from entity identifier to summary DTO - private Map buildRelatedEntitiesSummaryMapByPage(Page entities) { - return buildEntitiesSummariesMap( - getUniqueTargetIdentifiersInPage(entities)); - } + /// Gets all unique target entity identifiers from the relations of all entities + /// in a page. + /// + /// @param entities the page of entities to analyze + /// @return a list of unique target entity identifiers + private List getUniqueTargetIdentifiersInPage(Page entities) { + return new ArrayList<>(entities.stream() + .flatMap(entity -> entity.relations() == null + ? Stream.empty() + : entity.relations().stream().flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .collect(Collectors.toSet())); + } - /// Builds a map of entity summaries for a list of target identifiers. - /// - /// @param targetIdentifiers the list of target entity identifiers - /// @return a map from entity identifier to summary DTO - private Map buildEntitiesSummariesMap(List targetIdentifiers) { - return targetIdentifiers.isEmpty() - ? Collections.emptyMap() - : entityService.getEntitiesSummariesByIdentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); - } + /// Builds a map of entity summaries for all unique target identifiers in a page + /// of entities. + /// + /// @param entities the page of entities + /// @return a map from entity identifier to summary DTO + private Map buildRelatedEntitiesSummaryMapByPage( + Page entities) { + return buildEntitiesSummariesMap(getUniqueTargetIdentifiersInPage(entities)); + } + + /// Builds a map of entity summaries for a list of target identifiers. + /// + /// @param targetIdentifiers the list of target entity identifiers + /// @return a map from entity identifier to summary DTO + private Map buildEntitiesSummariesMap(List targetIdentifiers) { + return targetIdentifiers.isEmpty() + ? Collections.emptyMap() + : entityService.getEntitiesSummariesByIdentifiers(targetIdentifiers).stream() + .collect(Collectors.toMap(EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 61e07b1..4cc14a2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -5,7 +5,6 @@ import java.util.Optional; import java.util.UUID; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -16,6 +15,7 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; import com.decathlon.idp_core.infrastructure.adapters.persistence.specification.EntitySpecification; @@ -25,60 +25,67 @@ @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { - private final JpaEntityRepository jpaEntityRepository; - private final EntityPersistenceMapper mapper; - - @Override - public Entity save(Entity entity) { - return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); - } - - @Override - public Optional findById(UUID id) { - return jpaEntityRepository.findById(id).map(mapper::toDomain); - } - - @Override - public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier) { - return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) - .map(mapper::toDomain); - } - - @Override - public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { - return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) - .map(mapper::toDomain); - } - - @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return pageableEntity.map(mapper::toDomain); - } - - @Override - public Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable) { - Specification spec = EntitySpecification.of(templateIdentifier, filter); - return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); - } - - @Override - public List findByIdentifierIn(List identifiers) { - return jpaEntityRepository.findByIdentifierIn(identifiers); - } - - @Override - public List findByRelationIdIn(List relationIds) { - return jpaEntityRepository.findByRelationIdIn(relationIds); - } - - @Override - public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames) { - jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, propertyNames); - } - - @Override - public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { - jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); - } + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; + + @Override + public Entity save(Entity entity) { + return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); + } + + @Override + public Optional findById(UUID id) { + return jpaEntityRepository.findById(id).map(mapper::toDomain); + } + + @Override + public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier) { + return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) + .map(mapper::toDomain); + } + + @Override + public Optional findByTemplateIdentifierAndName(String templateIdentifier, + String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + + @Override + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return pageableEntity.map(mapper::toDomain); + } + + @Override + public Page findByTemplateIdentifierWithFilter(String templateIdentifier, + EntityFilter filter, Pageable pageable) { + Specification spec = EntitySpecification.of(templateIdentifier, filter); + return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + } + + @Override + public List findByIdentifierIn(List identifiers) { + return jpaEntityRepository.findByIdentifierIn(identifiers); + } + + @Override + public List findByRelationIdIn(List relationIds) { + return jpaEntityRepository.findByRelationIdIn(relationIds); + } + + @Override + public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames) { + jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, + propertyNames); + } + + @Override + public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames) { + jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + relationNames); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 848693d..72aea57 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -14,6 +14,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,37 +23,30 @@ @jakarta.persistence.Entity @Data @Table(name = "entity", uniqueConstraints = { - @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) -}) + @UniqueConstraint(columnNames = {"identifier", "template_identifier"})}) @Builder @NoArgsConstructor @AllArgsConstructor public class EntityJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(name = "template_identifier") - private String templateIdentifier; + @Column(name = "template_identifier") + private String templateIdentifier; - private String name; + private String name; - private String identifier; + private String identifier; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_properties", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "property_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "property_id"}), - indexes = @Index(columnList = "entity_id")) - private List properties; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_properties", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "property_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "property_id"}), indexes = @Index(columnList = "entity_id")) + private List properties; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_relations", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "relation_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "relation_id"}), - indexes = @Index(columnList = "entity_id")) - private List relations; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_relations", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "relation_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "relation_id"}), indexes = @Index(columnList = "entity_id")) + private List relations; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 500a325..1ddc3bb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -18,43 +18,159 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @Repository -public interface JpaEntityRepository extends JpaRepository, JpaSpecificationExecutor { - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") - List findByIdentifierIn(List identifiers); - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") - List findByRelationIdIn(List relationIds); - - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - - Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); - - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 - WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames - ) - """) - void deletePropertiesByTemplateIdentifierAndPropertyName( - @Param("templateIdentifier") String templateIdentifier, - @Param("propertyNames") Collection propertyNames); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM RelationJpaEntity r - WHERE r IN ( - SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 - WHERE e.templateIdentifier = :templateIdentifier - AND r2.name IN :relationNames - ) - """) - void deleteRelationsByTemplateIdentifierAndRelationName( - @Param("templateIdentifier") String templateIdentifier, - @Param("relationNames") Collection relationNames); +public interface JpaEntityRepository + extends + JpaRepository, + JpaSpecificationExecutor { + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") + List findByIdentifierIn(List identifiers); + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") + List findByRelationIdIn(List relationIds); + + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); + + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. Uses two separate queries to avoid Hibernate's + /// MultipleBagFetchException. First fetches entities with relations, then + /// fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations( + @Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. This is called after + /// findAllByIdentifierInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties( + @Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers(@Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the + /// given relation names. When the list is empty, all relation names are + /// followed + /// (no filter). The filter is applied inside both the outbound and inbound + /// recursive CTE steps so that only entities reachable through the specified + /// relations are returned, keeping the result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth, + @Param("relationNames") Collection relationNames); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM PropertyJpaEntity p + WHERE p IN ( + SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + WHERE e.templateIdentifier = :templateIdentifier + AND p2.name IN :propertyNames + ) + """) + void deletePropertiesByTemplateIdentifierAndPropertyName( + @Param("templateIdentifier") String templateIdentifier, + @Param("propertyNames") Collection propertyNames); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 + WHERE e.templateIdentifier = :templateIdentifier + AND r2.name IN :relationNames + ) + """) + void deleteRelationsByTemplateIdentifierAndRelationName( + @Param("templateIdentifier") String templateIdentifier, + @Param("relationNames") Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java index 0839c6e..8423e9e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java @@ -2,6 +2,13 @@ import java.util.stream.Stream; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + import org.springframework.data.jpa.domain.Specification; import com.decathlon.idp_core.domain.model.entity.EntityFilter; @@ -11,12 +18,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Subquery; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -40,184 +41,177 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySpecification { - private static final char LIKE_ESCAPE_CHAR = '\\'; - private static final String NAME = "name"; - private static final String IDENTIFIER = "identifier"; - private static final String RELATIONS = "relations"; - private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; - - /// Builds a [Specification] that matches entities belonging to the given template identifier - /// and satisfying all criteria in the given filter. - /// - /// @param templateIdentifier the template to scope the query to - /// @param filter the filter to apply; may be empty (no additional predicates) - /// @return a composed [Specification] combining template scope and all filter criteria - public static Specification of(String templateIdentifier, EntityFilter filter) { - var criteriaSpecs = filter.criteria().stream() - .map(EntitySpecification::fromCriterion); - - return Stream.concat( - Stream.of(hasTemplateIdentifier(templateIdentifier)), - criteriaSpecs - ).reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); - } - - private static Specification hasTemplateIdentifier(String templateIdentifier) { - return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); - } - - private static Specification fromCriterion(FilterCriterion criterion) { - return switch (criterion.keyType()) { - case ATTRIBUTE -> attributeSpec(criterion); - case PROPERTY -> propertySpec(criterion); - case RELATION_NAME -> relationNameSpec(criterion); - case RELATION_ENTITY -> relationEntitySpec(criterion); - case RELATION_PROPERTY -> relationPropertySpec(criterion); - case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); - case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); - }; - } - - private static Specification attributeSpec(FilterCriterion criterion) { - return (root, query, cb) -> - buildPredicate(cb, root.get(criterion.key()), criterion.operator(), criterion.value()); - } - - private static Specification propertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join propJoin = root.join("properties"); - return cb.and( - cb.equal(propJoin.get(NAME), criterion.key()), - buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value()) - ); - }; - } - - private static Specification relationEntitySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - return cb.and( - cb.equal(relJoin.get(NAME), criterion.key()), - buildPredicate(cb, targetJoin, criterion.operator(), criterion.value()) - ); - }; - } - - private static Specification relationPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); - - // Check if the property is a target entity property (identifier, name) - if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { - // Join to target entity identifiers first - Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - // Create a subquery to find the actual target entities and filter by their properties - var subquery = query.subquery(String.class); - var subRoot = subquery.from(EntityJpaEntity.class); - subquery.select(subRoot.get(IDENTIFIER)) - .where(buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); - - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(subquery) - ); - } else { - // Direct relation property (shouldn't happen normally as RelationJpaEntity has limited properties) - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value()) - ); - } - }; - } - - private static Predicate buildPredicate( - CriteriaBuilder cb, - Expression field, - FilterOperator operator, - String value) { - Expression stringField = field.as(String.class); - return switch (operator) { - case EQUALS -> cb.equal(cb.lower(stringField), value.toLowerCase()); - case CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); - } - case LESS_THAN -> cb.lessThan(stringField, value); - case GREATER_THAN -> cb.greaterThan(stringField, value); - }; - } - - private static Specification relationNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); - }; - } - - private static Specification relationsAsTargetNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - // Find entities whose identifier appears as a target in any relation whose name matches. - // Uses a correlated subquery to avoid joining through the entity's own outgoing relations. - Subquery subquery = query.subquery(String.class); - Root relRoot = subquery.from(RelationJpaEntity.class); - Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in any - /// relation whose **source entity** property matches the criterion. - /// - /// Example: `relations_as_target.api-link.name:microservice` returns entities that - /// are targeted by a `api-link` relation originating from an entity whose name - /// contains "microservice". - private static Specification relationsAsTargetPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" - - // Subquery: collect all target identifiers from relations named - // that originate from source entities whose matches. - Subquery subquery = query.subquery(String.class); - Root sourceRoot = subquery.from(EntityJpaEntity.class); - Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value()) - ); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are - /// treated as literal characters rather than pattern metacharacters. - static String escapeLikeWildcards(String value) { - return value - .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) - .replace("%", LIKE_ESCAPE_CHAR + "%") - .replace("_", LIKE_ESCAPE_CHAR + "_"); - } + private static final char LIKE_ESCAPE_CHAR = '\\'; + private static final String NAME = "name"; + private static final String IDENTIFIER = "identifier"; + private static final String RELATIONS = "relations"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + + /// Builds a [Specification] that matches entities belonging to the given + /// template identifier + /// and satisfying all criteria in the given filter. + /// + /// @param templateIdentifier the template to scope the query to + /// @param filter the filter to apply; may be empty (no additional predicates) + /// @return a composed [Specification] combining template scope and all filter + /// criteria + public static Specification of(String templateIdentifier, EntityFilter filter) { + var criteriaSpecs = filter.criteria().stream().map(EntitySpecification::fromCriterion); + + return Stream.concat(Stream.of(hasTemplateIdentifier(templateIdentifier)), criteriaSpecs) + .reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); + } + + private static Specification hasTemplateIdentifier(String templateIdentifier) { + return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); + } + + private static Specification fromCriterion(FilterCriterion criterion) { + return switch (criterion.keyType()) { + case ATTRIBUTE -> attributeSpec(criterion); + case PROPERTY -> propertySpec(criterion); + case RELATION_NAME -> relationNameSpec(criterion); + case RELATION_ENTITY -> relationEntitySpec(criterion); + case RELATION_PROPERTY -> relationPropertySpec(criterion); + case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); + case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); + }; + } + + private static Specification attributeSpec(FilterCriterion criterion) { + return (root, query, cb) -> buildPredicate(cb, root.get(criterion.key()), criterion.operator(), + criterion.value()); + } + + private static Specification propertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join propJoin = root.join("properties"); + return cb.and(cb.equal(propJoin.get(NAME), criterion.key()), + buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value())); + }; + } + + private static Specification relationEntitySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + return cb.and(cb.equal(relJoin.get(NAME), criterion.key()), + buildPredicate(cb, targetJoin, criterion.operator(), criterion.value())); + }; + } + + private static Specification relationPropertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); + + // Check if the property is a target entity property (identifier, name) + if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { + // Join to target entity identifiers first + Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + // Create a subquery to find the actual target entities and filter by their + // properties + var subquery = query.subquery(String.class); + var subRoot = subquery.from(EntityJpaEntity.class); + subquery.select(subRoot.get(IDENTIFIER)).where( + buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); + + return cb.and(cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(subquery)); + } else { + // Direct relation property (shouldn't happen normally as RelationJpaEntity has + // limited properties) + return cb.and(cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value())); + } + }; + } + + private static Predicate buildPredicate(CriteriaBuilder cb, Expression field, + FilterOperator operator, String value) { + Expression stringField = field.as(String.class); + return switch (operator) { + case EQUALS -> cb.equal(cb.lower(stringField), value.toLowerCase()); + case CONTAINS -> { + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case LESS_THAN -> cb.lessThan(stringField, value); + case GREATER_THAN -> cb.greaterThan(stringField, value); + }; + } + + private static Specification relationNameSpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); + }; + } + + private static Specification relationsAsTargetNameSpec( + FilterCriterion criterion) { + return (root, query, cb) -> { + // Find entities whose identifier appears as a target in any relation whose name + // matches. + // Uses a correlated subquery to avoid joining through the entity's own outgoing + // relations. + Subquery subquery = query.subquery(String.class); + Root relRoot = subquery.from(RelationJpaEntity.class); + Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in + /// any + /// relation whose **source entity** property matches the criterion. + /// + /// Example: `relations_as_target.api-link.name:microservice` returns entities + /// that + /// are targeted by a `api-link` relation originating from an entity whose name + /// contains "microservice". + private static Specification relationsAsTargetPropertySpec( + FilterCriterion criterion) { + return (root, query, cb) -> { + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" + + // Subquery: collect all target identifiers from relations named + // that originate from source entities whose matches. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin).where(cb.equal(relJoin.get(NAME), relationName), buildPredicate( + cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are + /// treated as literal characters rather than pattern metacharacters. + static String escapeLikeWildcards(String value) { + return value + .replace(String.valueOf(LIKE_ESCAPE_CHAR), + LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) + .replace("%", LIKE_ESCAPE_CHAR + "%").replace("_", LIKE_ESCAPE_CHAR + "_"); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java index d3502cc..2447737 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java @@ -24,504 +24,499 @@ @SuppressWarnings("java:S2187") class EntityQueryParserServiceTest { - private final EntityQueryParserService parser = new EntityQueryParserService(); - - private void assertSingleCriterion( - EntityFilter result, - FilterKeyType expectedKeyType, - String expectedKeyName, - FilterOperator expectedOperator, - String expectedValue) { - assertThat(result.criteria()).hasSize(1); - assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, expectedOperator, expectedValue); - } - - private void assertCriterion( - FilterCriterion criterion, - FilterKeyType expectedKeyType, - String expectedKeyName, - FilterOperator expectedOperator, - String expectedValue) { - assertThat(criterion.keyType()).isEqualTo(expectedKeyType); - assertThat(criterion.key()).isEqualTo(expectedKeyName); - assertThat(criterion.operator()).isEqualTo(expectedOperator); - assertThat(criterion.value()).isEqualTo(expectedValue); - } - - @Nested - @DisplayName("Attribute filters") - class AttributeFilterTests { - - @Test - @DisplayName("identifier equals") - void parse_attributeIdentifierEquals() { - var result = parser.parse("identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("name contains") - void parse_attributeNameContains() { - var result = parser.parse("name:API"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - } - } - - @Nested - @DisplayName("Property filters") - class PropertyFilterTests { - - @Test - @DisplayName("property equals") - void parse_propertyEquals() { - var result = parser.parse("property.language=JAVA"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("property contains") - void parse_propertyContains() { - var result = parser.parse("property.version:1.0"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, "1.0"); - } - - @Test - @DisplayName("property less than") - void parse_propertyLessThan() { - var result = parser.parse("property.port<9000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, "9000"); - } - - @Test - @DisplayName("property greater than") - void parse_propertyGreaterThan() { - var result = parser.parse("property.port>1000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, "1000"); - } - } - - @Nested - @DisplayName("Relation name filters") - class RelationNameFilterTests { - - @Test - @DisplayName("relation name equals") - void parse_relationNameEquals() { - var result = parser.parse("relation=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relation name contains") - void parse_relationNameContains() { - var result = parser.parse("relation:rover"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, "rover"); - } - } - - @Nested - @DisplayName("Relation entity filters") - class RelationEntityFilterTests { - - @Test - @DisplayName("relation entity equals") - void parse_relationEntityEquals() { - var result = parser.parse("relation.database=my-db"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - } - - @Test - @DisplayName("relation entity contains") - void parse_relationEntityContains() { - var result = parser.parse("relation.database:my"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.CONTAINS, "my"); - } - } - - @Nested - @DisplayName("Relation property filters") - class RelationPropertyFilterTests { - - @Test - @DisplayName("relation property equals") - void parse_relationPropertyEquals() { - var result = parser.parse("relation.api-link.identifier=microservice-1"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "microservice-1"); - } - - @Test - @DisplayName("relation property contains") - void parse_relationPropertyContains() { - var result = parser.parse("relation.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unsupported property in relation (custom-prop is not identifier or name)") - void parse_relationPropertyUnsupported_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("custom-prop") - .hasMessageContaining("identifier") - .hasMessageContaining("name"); - } - } - - @Nested - @DisplayName("Relations as target filters") - class RelationsAsTargetFilterTests { - - @Test - @DisplayName("relations_as_target name equals") - void parse_relationsAsTargetNameEquals() { - var result = parser.parse("relations_as_target=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relations_as_target name contains") - void parse_relationsAsTargetNameContains() { - var result = parser.parse("relations_as_target:rover"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.CONTAINS, "rover"); - } - - @Test - @DisplayName("relations_as_target property identifier equals") - void parse_relationsAsTargetPropertyIdentifierEquals() { - var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("relations_as_target property name contains") - void parse_relationsAsTargetPropertyNameContains() { - var result = parser.parse("relations_as_target.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws exception for unsupported property in relations_as_target") - void parse_relationsAsTargetInvalidProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("only 'identifier' and 'name' are supported"); - } - - @Test - @DisplayName("throws exception for relations_as_target without property") - void parse_relationsAsTargetWithoutProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("relations_as_target requires the form"); - } - } - - @Nested - @DisplayName("Combined AND criteria") - class CombinedCriteriaTests { - - @Test - @DisplayName("two criteria separated by semicolon") - void parse_twoCriteriaWithSemicolon() { - var result = parser.parse("name:API;property.language=JAVA"); - assertThat(result.criteria()).hasSize(2); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("four criteria of different key types") - void parse_fourCriteria() { - var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); - assertThat(result.criteria()).hasSize(4); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); - } - - @Test - @DisplayName("five criteria including relation property and reverse relation") - void parse_fiveCriteriaWithRelationProperty() { - var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); - assertThat(result.criteria()).hasSize(5); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); - assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "owned_by.name", FilterOperator.CONTAINS, "platform"); - } - } - - @Nested - @DisplayName("Invalid query syntax") - class InvalidQueryTests { - - @ParameterizedTest(name = "missing operator in: ''{0}''") - @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) - @DisplayName("throws InvalidQueryDslException when operator is missing") - void parse_missingOperator_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unknown attribute") - void parse_unknownAttribute_throwsException() { - assertThatThrownBy(() -> parser.parse("unknownField=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("Unknown attribute"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank value") - void parse_blankValue_throwsException() { - assertThatThrownBy(() -> parser.parse("name=")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("value must not be blank"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank key") - void parse_blankKey_throwsException() { - assertThatThrownBy(() -> parser.parse("=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("key must not be blank"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank property name after prefix") - void parse_blankPropertyName_throwsException() { - assertThatThrownBy(() -> parser.parse("property.=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("key name must not be blank"); - } - } - - @Nested - @DisplayName("Security constraints") - class SecurityConstraintTests { - - @Test - @DisplayName("throws InvalidQueryDslException when criteria count exceeds limit") - void parse_tooManyCriteria_throwsException() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" - + "property.k=11"; - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("maximum of %d".formatted(EntityQueryParserService.MAX_CRITERIA_COUNT)); - } - - @Test - @DisplayName("accepts exactly the maximum number of criteria") - void parse_exactlyMaxCriteria_succeeds() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(EntityQueryParserService.MAX_CRITERIA_COUNT); - } - - @Test - @DisplayName("throws InvalidQueryDslException when value exceeds max length") - void parse_valueTooLong_throwsException() { - var longValue = "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH + 1); - assertThatThrownBy(() -> parser.parse("name=" + longValue)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); - } - - @Test - @DisplayName("throws InvalidQueryDslException when key exceeds max length") - void parse_keyTooLong_throwsException() { - var longKey = "property." + "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH); - assertThatThrownBy(() -> parser.parse(longKey + "=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); - } - - @ParameterizedTest(name = "valid key name: ''{0}''") - @ValueSource(strings = { - "property.language=JAVA", - "property.my-key=value", - "property.my_key=value", - "property.key123=value", - "property.lang@ge=JAVA", - "property.my key=JAVA", - "property.lang/age=JAVA", - "relation.database=my-db", - "relation.db$name=my-db", - "relation.my-cache.identifier=redis-1" - }) - @DisplayName("accepts valid key name characters") - void parse_validKeyNameChars_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Duplicate criterion detection") - class DuplicateCriterionTests { - - @ParameterizedTest(name = "duplicate criterion in: ''{0}''") - @ValueSource(strings = { - "name=A;name=B", - "property.language=JAVA;property.language=PYTHON", - "relation=api-link;relation=database" - }) - @DisplayName("throws InvalidQueryDslException for duplicate criteria") - void parse_duplicateCriterion_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - - @Test - @DisplayName("accepts distinct attribute criteria") - void parse_distinctAttributeCriteria_succeeds() { - var result = parser.parse("identifier=web-api-1;name=Web API 1"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("accepts distinct property criteria") - void parse_distinctPropertyCriteria_succeeds() { - var result = parser.parse("property.language=JAVA;property.environment=PROD"); - assertThat(result.criteria()).hasSize(2); - } - } - - @Nested - @DisplayName("Type mismatch validation") - class TypeMismatchTests { - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relationapi-link"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation name") - void parse_comparisonOnRelationName_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.databasemy-db"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation entity") - void parse_comparisonOnRelationEntity_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.database.templatepostgresql"}) - @DisplayName("throws InvalidQueryDslException for unsupported property on relation (template is not a valid relation property)") - void parse_comparisonOnRelationTemplate_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("template"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unsupported property on relation with equals operator") - void parse_equalsOnRelationTemplate_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("template") - .hasMessageContaining("identifier") - .hasMessageContaining("name"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation property") - void parse_comparisonOnRelationProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relations_as_target property") - void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"nameA", "identifier parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"property.port<9000", "property.port>1000"}) - @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") - void parse_comparisonOnProperty_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Edge cases") - class EdgeCaseTests { - - @Test - @DisplayName("consecutive semicolons produce empty filter") - void parse_consecutiveSemicolons_ignoresEmptyTokens() { - var result = parser.parse("name=API;;property.lang=JAVA"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("trailing semicolon is ignored") - void parse_trailingSemicolon_ignored() { - var result = parser.parse("name=API;"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("leading semicolon is ignored") - void parse_leadingSemicolon_ignored() { - var result = parser.parse(";name=API"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("values containing SQL LIKE wildcards are accepted") - void parse_valuesWithLikeWildcards_accepted() { - var result = parser.parse("name:100%_success"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "100%_success"); - } - } - - @Nested - @DisplayName("Null or blank query") - class NullOrBlankQueryTests { - - @ParameterizedTest(name = "returns empty filter for: {0}") - @MethodSource("provideNullOrBlankQueries") - @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") - void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).isEmpty(); - } - - private static Stream provideNullOrBlankQueries() { - return Stream.of( - Arguments.of((String) null), - Arguments.of(""), - Arguments.of(" ") - ); - } + private final EntityQueryParserService parser = new EntityQueryParserService(); + + private void assertSingleCriterion(EntityFilter result, FilterKeyType expectedKeyType, + String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { + assertThat(result.criteria()).hasSize(1); + assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, + expectedOperator, expectedValue); + } + + private void assertCriterion(FilterCriterion criterion, FilterKeyType expectedKeyType, + String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { + assertThat(criterion.keyType()).isEqualTo(expectedKeyType); + assertThat(criterion.key()).isEqualTo(expectedKeyName); + assertThat(criterion.operator()).isEqualTo(expectedOperator); + assertThat(criterion.value()).isEqualTo(expectedValue); + } + + @Nested + @DisplayName("Attribute filters") + class AttributeFilterTests { + + @Test + @DisplayName("identifier equals") + void parse_attributeIdentifierEquals() { + var result = parser.parse("identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, + "web-api-1"); } + @Test + @DisplayName("name contains") + void parse_attributeNameContains() { + var result = parser.parse("name:API"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, + "API"); + } + } + + @Nested + @DisplayName("Property filters") + class PropertyFilterTests { + + @Test + @DisplayName("property equals") + void parse_propertyEquals() { + var result = parser.parse("property.language=JAVA"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, + "JAVA"); + } + + @Test + @DisplayName("property contains") + void parse_propertyContains() { + var result = parser.parse("property.version:1.0"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, + "1.0"); + } + + @Test + @DisplayName("property less than") + void parse_propertyLessThan() { + var result = parser.parse("property.port<9000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, + "9000"); + } + + @Test + @DisplayName("property greater than") + void parse_propertyGreaterThan() { + var result = parser.parse("property.port>1000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, + "1000"); + } + } + + @Nested + @DisplayName("Relation name filters") + class RelationNameFilterTests { + + @Test + @DisplayName("relation name equals") + void parse_relationNameEquals() { + var result = parser.parse("relation=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, + "api-link"); + } + + @Test + @DisplayName("relation name contains") + void parse_relationNameContains() { + var result = parser.parse("relation:rover"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, + "rover"); + } + } + + @Nested + @DisplayName("Relation entity filters") + class RelationEntityFilterTests { + + @Test + @DisplayName("relation entity equals") + void parse_relationEntityEquals() { + var result = parser.parse("relation.database=my-db"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + } + + @Test + @DisplayName("relation entity contains") + void parse_relationEntityContains() { + var result = parser.parse("relation.database:my"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.CONTAINS, "my"); + } + } + + @Nested + @DisplayName("Relation property filters") + class RelationPropertyFilterTests { + + @Test + @DisplayName("relation property equals") + void parse_relationPropertyEquals() { + var result = parser.parse("relation.api-link.identifier=microservice-1"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", + FilterOperator.EQUALS, "microservice-1"); + } + + @Test + @DisplayName("relation property contains") + void parse_relationPropertyContains() { + var result = parser.parse("relation.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", + FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for unsupported property in relation (custom-prop is not identifier or name)") + void parse_relationPropertyUnsupported_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("custom-prop") + .hasMessageContaining("identifier").hasMessageContaining("name"); + } + } + + @Nested + @DisplayName("Relations as target filters") + class RelationsAsTargetFilterTests { + + @Test + @DisplayName("relations_as_target name equals") + void parse_relationsAsTargetNameEquals() { + var result = parser.parse("relations_as_target=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", + FilterOperator.EQUALS, "api-link"); + } + + @Test + @DisplayName("relations_as_target name contains") + void parse_relationsAsTargetNameContains() { + var result = parser.parse("relations_as_target:rover"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", + FilterOperator.CONTAINS, "rover"); + } + + @Test + @DisplayName("relations_as_target property identifier equals") + void parse_relationsAsTargetPropertyIdentifierEquals() { + var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); + } + + @Test + @DisplayName("relations_as_target property name contains") + void parse_relationsAsTargetPropertyNameContains() { + var result = parser.parse("relations_as_target.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", + FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws exception for unsupported property in relations_as_target") + void parse_relationsAsTargetInvalidProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) + .isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("only 'identifier' and 'name' are supported"); + } + + @Test + @DisplayName("throws exception for relations_as_target without property") + void parse_relationsAsTargetWithoutProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) + .isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("relations_as_target requires the form"); + } + } + + @Nested + @DisplayName("Combined AND criteria") + class CombinedCriteriaTests { + + @Test + @DisplayName("two criteria separated by semicolon") + void parse_twoCriteriaWithSemicolon() { + var result = parser.parse("name:API;property.language=JAVA"); + assertThat(result.criteria()).hasSize(2); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + } + + @Test + @DisplayName("four criteria of different key types") + void parse_fourCriteria() { + var result = parser.parse( + "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); + assertThat(result.criteria()).hasSize(4); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "service-1"); + } + + @Test + @DisplayName("five criteria including relation property and reverse relation") + void parse_fiveCriteriaWithRelationProperty() { + var result = parser.parse( + "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); + assertThat(result.criteria()).hasSize(5); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "service-1"); + assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, + "owned_by.name", FilterOperator.CONTAINS, "platform"); + } + } + + @Nested + @DisplayName("Invalid query syntax") + class InvalidQueryTests { + + @ParameterizedTest(name = "missing operator in: ''{0}''") + @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) + @DisplayName("throws InvalidQueryDslException when operator is missing") + void parse_missingOperator_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); + } + + @Test + @DisplayName("throws InvalidQueryDslException for unknown attribute") + void parse_unknownAttribute_throwsException() { + assertThatThrownBy(() -> parser.parse("unknownField=value")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("Unknown attribute"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for blank value") + void parse_blankValue_throwsException() { + assertThatThrownBy(() -> parser.parse("name=")).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("value must not be blank"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for blank key") + void parse_blankKey_throwsException() { + assertThatThrownBy(() -> parser.parse("=value")).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("key must not be blank"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for blank property name after prefix") + void parse_blankPropertyName_throwsException() { + assertThatThrownBy(() -> parser.parse("property.=JAVA")) + .isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("key name must not be blank"); + } + } + + @Nested + @DisplayName("Security constraints") + class SecurityConstraintTests { + + @Test + @DisplayName("throws InvalidQueryDslException when criteria count exceeds limit") + void parse_tooManyCriteria_throwsException() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" + "property.k=11"; + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining( + "maximum of %d".formatted(EntityQueryParserService.MAX_CRITERIA_COUNT)); + } + + @Test + @DisplayName("accepts exactly the maximum number of criteria") + void parse_exactlyMaxCriteria_succeeds() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(EntityQueryParserService.MAX_CRITERIA_COUNT); + } + + @Test + @DisplayName("throws InvalidQueryDslException when value exceeds max length") + void parse_valueTooLong_throwsException() { + var longValue = "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH + 1); + assertThatThrownBy(() -> parser.parse("name=" + longValue)) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( + "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); + } + + @Test + @DisplayName("throws InvalidQueryDslException when key exceeds max length") + void parse_keyTooLong_throwsException() { + var longKey = "property." + "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH); + assertThatThrownBy(() -> parser.parse(longKey + "=value")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( + "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); + } + + @ParameterizedTest(name = "valid key name: ''{0}''") + @ValueSource(strings = {"property.language=JAVA", "property.my-key=value", + "property.my_key=value", "property.key123=value", "property.lang@ge=JAVA", + "property.my key=JAVA", "property.lang/age=JAVA", "relation.database=my-db", + "relation.db$name=my-db", "relation.my-cache.identifier=redis-1"}) + @DisplayName("accepts valid key name characters") + void parse_validKeyNameChars_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Duplicate criterion detection") + class DuplicateCriterionTests { + + @ParameterizedTest(name = "duplicate criterion in: ''{0}''") + @ValueSource(strings = {"name=A;name=B", "property.language=JAVA;property.language=PYTHON", + "relation=api-link;relation=database"}) + @DisplayName("throws InvalidQueryDslException for duplicate criteria") + void parse_duplicateCriterion_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + + @Test + @DisplayName("accepts distinct attribute criteria") + void parse_distinctAttributeCriteria_succeeds() { + var result = parser.parse("identifier=web-api-1;name=Web API 1"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("accepts distinct property criteria") + void parse_distinctPropertyCriteria_succeeds() { + var result = parser.parse("property.language=JAVA;property.environment=PROD"); + assertThat(result.criteria()).hasSize(2); + } + } + + @Nested + @DisplayName("Type mismatch validation") + class TypeMismatchTests { + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relationapi-link"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relation name") + void parse_comparisonOnRelationName_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.databasemy-db"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relation entity") + void parse_comparisonOnRelationEntity_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.database.templatepostgresql"}) + @DisplayName("throws InvalidQueryDslException for unsupported property on relation (template is not a valid relation property)") + void parse_comparisonOnRelationTemplate_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("template"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for unsupported property on relation with equals operator") + void parse_equalsOnRelationTemplate_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("template") + .hasMessageContaining("identifier").hasMessageContaining("name"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relation property") + void parse_comparisonOnRelationProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relations_as_target property") + void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"nameA", "identifier parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"property.port<9000", "property.port>1000"}) + @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") + void parse_comparisonOnProperty_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCaseTests { + + @Test + @DisplayName("consecutive semicolons produce empty filter") + void parse_consecutiveSemicolons_ignoresEmptyTokens() { + var result = parser.parse("name=API;;property.lang=JAVA"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("trailing semicolon is ignored") + void parse_trailingSemicolon_ignored() { + var result = parser.parse("name=API;"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("leading semicolon is ignored") + void parse_leadingSemicolon_ignored() { + var result = parser.parse(";name=API"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("values containing SQL LIKE wildcards are accepted") + void parse_valuesWithLikeWildcards_accepted() { + var result = parser.parse("name:100%_success"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, + "100%_success"); + } + } + + @Nested + @DisplayName("Null or blank query") + class NullOrBlankQueryTests { + + @ParameterizedTest(name = "returns empty filter for: {0}") + @MethodSource("provideNullOrBlankQueries") + @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") + void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).isEmpty(); + } + + private static Stream provideNullOrBlankQueries() { + return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of(" ")); + } + } + } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 001e221..747c760 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -24,11 +24,11 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; @@ -42,201 +42,218 @@ @DisplayName("EntityService Tests") class EntityServiceTest { - @Mock - private EntityRepositoryPort entityRepository; - - - @Mock - private EntityValidationService entityValidationService; - - @Mock - private EntityTemplateValidationService entityTemplateValidationService; - - @Mock - private EntityTemplateService entityTemplateService; - - @Mock - private EntityQueryParserService entityQueryParserService; - - @InjectMocks - private EntityService entityService; - - @Test - @DisplayName("Should return entities page by template identifier") - void shouldReturnEntitiesByTemplateIdentifier() { - var pageable = Pageable.ofSize(10); - var entity = entity("template-a", "entity-a", "Entity A"); - var page = new PageImpl<>(List.of(entity)); - var template = new EntityTemplate(UUID.randomUUID(), "template-a", "Template A", "desc", List.of(), - List.of()); - - when(entityTemplateService.getEntityTemplateByIdentifier("template-a")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), pageable)) - .thenReturn(page); - - var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a", null); - - assertSame(page, result); - verify(entityTemplateService).getEntityTemplateByIdentifier("template-a"); - verify(entityQueryParserService).validateFilterPropertyTypes(EntityFilter.empty(), template); - verify(entityRepository).findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), pageable); - } - - @Test - @DisplayName("Should return entity summaries by identifiers") - void shouldReturnEntitySummariesByIdentifiers() { - var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); - when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); - - var result = entityService.getEntitiesSummariesByIdentifiers(List.of("service-a")); - - assertEquals(summaries, result); - verify(entityRepository).findByIdentifierIn(List.of("service-a")); - } - - @Test - @DisplayName("Should return entity by template and identifier") - void shouldReturnEntityByTemplateAndIdentifier() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - - assertSame(entity, result); - verify(entityTemplateValidationService).validateTemplateExists("web-service"); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - } - - @Test - @DisplayName("Should throw when entity is not found for template") - void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) - .thenReturn(Optional.empty()); - - assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); - } - - @Test - @DisplayName("Should create entity when validations pass") - void shouldCreateEntityWhenValidationsPass() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.save(entity)).thenReturn(entity); - - var result = entityService.createEntity(entity); - - assertSame(entity, result); - - InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateForCreation(entity, template); - inOrder.verify(entityRepository).save(entity); - verifyNoInteractions(entityTemplateValidationService); - } - - @Test - @DisplayName("Should not save when entity already exists") - void shouldNotSaveWhenEntityAlreadyExists() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); - - assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateForCreation(entity, template); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should stop immediately when template does not exist") - void shouldStopWhenTemplateDoesNotExistOnCreate() { - var entity = entity("missing-template", "catalog-api", "Catalog API"); - - when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) - .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); - verifyNoInteractions(entityValidationService); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should update entity when validations pass") - void shouldUpdateEntityWhenValidationsPass() { - var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), List.of()); - var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var expectedSaved = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.of(existing)); - when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); - - var result = entityService.updateEntity("web-service", "web-api-2", payload); - - assertSame(expectedSaved, result); - InOrder inOrder = inOrder(entityTemplateService, entityRepository, entityValidationService, entityRepository); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); - inOrder.verify(entityValidationService).validateForUpdate(expectedSaved, template); - inOrder.verify(entityRepository).save(expectedSaved); - } - - @Test - @DisplayName("Should throw when updating non-existing entity") - void shouldThrowWhenUpdatingNonExistingEntity() { - var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.empty()); - - assertThrows(EntityNotFoundException.class, - () -> entityService.updateEntity("web-service", "web-api-2", payload)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should propagate two validation errors when update payload violates template constraints") - void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { - var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), List.of()); - var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var expectedToValidate = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - var validationErrors = List.of( - ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE.formatted("status", "web-service"), - ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND.formatted("child_of", "missing-platform") - ); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.of(existing)); - doThrow(new EntityValidationException(validationErrors)) - .when(entityValidationService).validateForUpdate(expectedToValidate, template); - - var thrown = assertThrows(EntityValidationException.class, - () -> entityService.updateEntity("web-service", "web-api-2", payload)); - - assertEquals(validationErrors, thrown.getViolations()); - verify(entityValidationService).validateForUpdate(expectedToValidate, template); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); - verifyNoMoreInteractions(entityRepository); - } - - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); - } + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @Mock + private EntityTemplateService entityTemplateService; + + @Mock + private EntityQueryParserService entityQueryParserService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + var template = new EntityTemplate(UUID.randomUUID(), "template-a", "Template A", "desc", + List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("template-a")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), + pageable)).thenReturn(page); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a", null); + + assertSame(page, result); + verify(entityTemplateService).getEntityTemplateByIdentifier("template-a"); + verify(entityQueryParserService).validateFilterPropertyTypes(EntityFilter.empty(), template); + verify(entityRepository).findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), + pageable); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIdentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", + "catalog-api"); + + assertSame(entity, result); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> entityService + .getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateForCreation(entity, template); + inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateForCreation(entity, template); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should update entity when validations pass") + void shouldUpdateEntityWhenValidationsPass() { + var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), + List.of()); + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), + List.of()); + var expectedSaved = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", + List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")) + .thenReturn(Optional.of(existing)); + when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); + + var result = entityService.updateEntity("web-service", "web-api-2", payload); + + assertSame(expectedSaved, result); + InOrder inOrder = inOrder(entityTemplateService, entityRepository, entityValidationService, + entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", + "web-api-2"); + inOrder.verify(entityValidationService).validateForUpdate(expectedSaved, template); + inOrder.verify(entityRepository).save(expectedSaved); + } + + @Test + @DisplayName("Should throw when updating non-existing entity") + void shouldThrowWhenUpdatingNonExistingEntity() { + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), + List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.updateEntity("web-service", "web-api-2", payload)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should propagate two validation errors when update payload violates template constraints") + void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { + var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), + List.of()); + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), + List.of()); + var expectedToValidate = new Entity(existing.id(), "web-service", "Web API 2 Updated", + "web-api-2", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + var validationErrors = List.of( + ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE.formatted("status", "web-service"), + ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND.formatted("child_of", + "missing-platform")); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")) + .thenReturn(Optional.of(existing)); + doThrow(new EntityValidationException(validationErrors)).when(entityValidationService) + .validateForUpdate(expectedToValidate, template); + + var thrown = assertThrows(EntityValidationException.class, + () -> entityService.updateEntity("web-service", "web-api-2", payload)); + + assertEquals(validationErrors, thrown.getViolations()); + verify(entityValidationService).validateForUpdate(expectedToValidate, template); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + verifyNoMoreInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 2f795a9..0ea9653 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -38,125 +38,95 @@ @DisplayName("EntityValidationService Tests") class EntityValidationServiceTest { - @Mock - private EntityRepositoryPort entityRepository; - - @Mock - private RelationValidationService relationValidationService; - - @Mock - private PropertyValidationService propertyValidationService; - - @InjectMocks - private EntityValidationService entityValidationService; - - @Test - @DisplayName("Should throw when entity with same identifier already exists") - void shouldThrowWhenEntityAlreadyExists() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); - } + @Mock + private EntityRepositoryPort entityRepository; - @Test - @DisplayName("Should not query repository when identifier is null") - void shouldNotQueryRepositoryWhenIdentifierIsNull() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); + @Mock + private RelationValidationService relationValidationService; - var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + @Mock + private PropertyValidationService propertyValidationService; - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + @InjectMocks + private EntityValidationService entityValidationService; - verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(any(), any()); - } + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); - @Test - @DisplayName("Should validate entity successfully by delegating to property and relation validation services") - void shouldValidateForCreationSuccessfullyWhenNoViolations() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(), - List.of()); - - var property = new Property(UUID.randomUUID(), "version", "1.0.0"); - var relation = new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a")); - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(property), - List.of(relation)); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - - verify(propertyValidationService).validatePropertiesAgainstTemplate( - eq(template), - eq(template.propertiesDefinitions()), - eq(Map.of("version", property)), - any(Violations.class) - ); - - verify(relationValidationService).validateRelationsAgainstTemplate( - eq(template), - eq(entity.relations()), - any(Violations.class) - ); - } + assertThrows(EntityAlreadyExistsException.class, + () -> entityValidationService.validateForCreation(entity, template)); + } - @Test - @DisplayName("Should throw EntityValidationException when delegated validations populate the Violations aggregate") - void shouldThrowEntityValidationExceptionWhenViolationsExist() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(), - List.of()); - - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - - try (var _ = mockConstruction(Violations.class, - (mock, context) -> { - when(mock.isEmpty()).thenReturn(false); - when(mock.asList()).thenReturn(List.of("Delegated property error", "Delegated relation error")); - })) { - - var exception = assertThrows(EntityValidationException.class, - () -> entityValidationService.validateForCreation(entity, template)); - - assertEquals(2, exception.getViolations().size()); - assertEquals("Delegated property error", exception.getViolations().get(0)); - - verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), any(), any(), any()); - verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), any(), any()); - } - } + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(any(), any()); + } + + @Test + @DisplayName("Should validate entity successfully by delegating to property and relation validation services") + void shouldValidateForCreationSuccessfullyWhenNoViolations() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + var property = new Property(UUID.randomUUID(), "version", "1.0.0"); + var relation = new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a")); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(property), + List.of(relation)); - private Entity entity( - String templateIdentifier, - String identifier, - String name, - List properties, - List relations) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), + eq(template.propertiesDefinitions()), eq(Map.of("version", property)), + any(Violations.class)); + + verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), + eq(entity.relations()), any(Violations.class)); + } + + @Test + @DisplayName("Should throw EntityValidationException when delegated validations populate the Violations aggregate") + void shouldThrowEntityValidationExceptionWhenViolationsExist() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + try (var _ = mockConstruction(Violations.class, (mock, context) -> { + when(mock.isEmpty()).thenReturn(false); + when(mock.asList()) + .thenReturn(List.of("Delegated property error", "Delegated relation error")); + })) { + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + assertEquals(2, exception.getViolations().size()); + assertEquals("Delegated property error", exception.getViolations().get(0)); + + verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), any(), + any(), any()); + verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), any(), + any()); } + } + + private Entity entity(String templateIdentifier, String identifier, String name, + List properties, List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + relations); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 5eee5bc..520debc 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -7,8 +7,8 @@ import java.util.List; import java.util.Map; -import java.util.stream.Stream; import java.util.UUID; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -17,7 +17,6 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; - import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; @@ -30,426 +29,480 @@ @DisplayName("PropertyValidationService Tests") class PropertyValidationServiceTest { - private final PropertyValidationService service = new PropertyValidationService(); - - @Nested - @DisplayName("validatePropertiesAgainstTemplate Orchestration Tests") - class AgainstTemplateValidationTests { - - @Test - @DisplayName("Should report violation when required property is completely missing") - void shouldReportViolationWhenRequiredPropertyIsMissing() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); - var violations = mock(Violations.class); - - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), violations); - - verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); - } - - @Test - @DisplayName("Should report violation when required property is present but blank") - void shouldReportViolationWhenRequiredPropertyIsBlank() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); - var property = new Property(UUID.randomUUID(), "owner", " "); - var violations = mock(Violations.class); - - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("owner", property), violations); - - verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); - } - - @Test - @DisplayName("Should not report violation when optional property is missing") - void shouldNotReportViolationWhenOptionalPropertyIsMissing() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "description", "Desc", PropertyType.STRING, false, null); - var violations = mock(Violations.class); - - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), violations); + private final PropertyValidationService service = new PropertyValidationService(); - verifyNoInteractions(violations); - } + @Nested + @DisplayName("validatePropertiesAgainstTemplate Orchestration Tests") + class AgainstTemplateValidationTests { - @Test - @DisplayName("Should report violation when provided property is not defined in template") - void shouldReportViolationWhenPropertyNotDefinedInTemplate() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); - var extraProperty = new Property(UUID.randomUUID(), "status", "deprecated"); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required property is completely missing") + void shouldReportViolationWhenRequiredPropertyIsMissing() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", + PropertyType.STRING, true, null); + var violations = mock(Violations.class); - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("status", extraProperty), violations); + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), + violations); - verify(violations).add(ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE, "status", "system-template"); - verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); - } + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", + "system-template"); + } - @Test - @DisplayName("Should delegate to validatePropertyValue and accumulate rule violations") - void shouldDelegateAndAccumulateRuleViolations() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", PropertyType.NUMBER, true, null); - var property = new Property(UUID.randomUUID(), "port", "not-a-number"); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required property is present but blank") + void shouldReportViolationWhenRequiredPropertyIsBlank() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", + PropertyType.STRING, true, null); + var property = new Property(UUID.randomUUID(), "owner", " "); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("owner", property), violations); + + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", + "system-template"); + } - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("port", property), violations); + @Test + @DisplayName("Should not report violation when optional property is missing") + void shouldNotReportViolationWhenOptionalPropertyIsMissing() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "description", "Desc", + PropertyType.STRING, false, null); + var violations = mock(Violations.class); - verify(violations).add(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("port", PropertyType.NUMBER)); - } + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), + violations); - @Test - @DisplayName("Should add no violations when required property is present and valid") - void shouldAddNoViolationsWhenValid() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", PropertyType.NUMBER, true, null); - var property = new Property(UUID.randomUUID(), "port", "8080"); - var violations = mock(Violations.class); + verifyNoInteractions(violations); + } - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("port", property), violations); + @Test + @DisplayName("Should report violation when provided property is not defined in template") + void shouldReportViolationWhenPropertyNotDefinedInTemplate() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", + PropertyType.STRING, true, null); + var extraProperty = new Property(UUID.randomUUID(), "status", "deprecated"); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("status", extraProperty), violations); + + verify(violations).add(ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE, "status", + "system-template"); + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", + "system-template"); + } - verifyNoInteractions(violations); - } + @Test + @DisplayName("Should delegate to validatePropertyValue and accumulate rule violations") + void shouldDelegateAndAccumulateRuleViolations() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", + PropertyType.NUMBER, true, null); + var property = new Property(UUID.randomUUID(), "port", "not-a-number"); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("port", property), violations); + + verify(violations) + .add(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("port", PropertyType.NUMBER)); } - @Nested - @DisplayName("STRING validation") - class StringValidationTests { + @Test + @DisplayName("Should add no violations when required property is present and valid") + void shouldAddNoViolationsWhenValid() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", + PropertyType.NUMBER, true, null); + var property = new Property(UUID.randomUUID(), "port", "8080"); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report type mismatch when STRING value is null") - void shouldReportTypeMismatchWhenStringValueIsNull() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("port", property), violations); - var violations = service.validatePropertyValue(definition, null); + verifyNoInteractions(violations); + } + } - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - @Test - @DisplayName("Should return no violations when STRING has no rules") - void shouldReturnNoViolationsWhenStringHasNoRules() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, null); - assertEquals(List.of(), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), + violations); + } - @Test - @DisplayName("Should return no violations when STRING value satisfies all rules") - void shouldReturnNoViolationsWhenStringPassesAllRules() { - var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); - var definition = propertyDefinition("env", PropertyType.STRING, rules); + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "hello"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minLength violation") - void shouldReportMinLengthViolation() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, + null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "dev"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report maxLength violation") - void shouldReportMaxLengthViolation() { - var rules = new PropertyRules(null, null, null, null, 5, null, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "ab"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should report regex violation") - void shouldReportRegexViolation() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "too-long-value"); - assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should accept value matching regex") - void shouldAcceptValueMatchingRegex() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "abc"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), + violations); + } - @Test - @DisplayName("Should report enum violation when value not in allowed list") - void shouldReportEnumViolation() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "12345"); - assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should accept enum value with case-insensitive match") - void shouldAcceptEnumValueCaseInsensitive() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", + List.of("ACTIVE", "INACTIVE"))), violations); + } - @Test - @DisplayName("Should skip enum check when enumValues is empty") - void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { - var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "active"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report format violation for invalid EMAIL") - void shouldReportFormatViolationForInvalidEmail() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "anything"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should accept valid EMAIL format") - void shouldAcceptValidEmailFormat() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "not-an-email"); - assertEquals(List.of(), violations); - } + assertEquals(List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), + violations); + } - @Test - @DisplayName("Should report format violation for invalid URL") - void shouldReportFormatViolationForInvalidUrl() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "user@example.com"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should accept valid URL format") - void shouldAcceptValidUrlFormat() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "not-a-url"); - assertEquals(List.of(), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), + violations); + } - @Test - @DisplayName("Should report multiple violations at once") - void shouldReportMultipleStringViolations() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - assertEquals(4, violations.size()); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should use cached pattern for repeated regex validations") - void shouldUseCachedPatternForRepeatedRegex() { - var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", + 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + var violations = service.validatePropertyValue(definition, "AA"); - assertEquals(List.of(), violations1); - assertEquals(List.of(), violations2); - } + assertEquals(4, violations.size()); } - @Nested - @DisplayName("NUMBER validation") - class NumberValidationTests { + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - @Test - @DisplayName("Should report type mismatch when NUMBER value is null") - void shouldReportTypeMismatchWhenNumberValueIsNull() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, null); + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } + } + + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch when NUMBER value is null") + void shouldReportTypeMismatchWhenNumberValueIsNull() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + var violations = service.validatePropertyValue(definition, null); + + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), + violations); + } - @Test - @DisplayName("Should report type mismatch for non-numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), + violations); + } - @Test - @DisplayName("Should accept primitive/boxed Number objects") - void shouldAcceptBoxedNumberObjects() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violationsInt = service.validatePropertyValue(definition, 42); - var violationsDouble = service.validatePropertyValue(definition, 42.5); + @Test + @DisplayName("Should accept primitive/boxed Number objects") + void shouldAcceptBoxedNumberObjects() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + var violationsInt = service.validatePropertyValue(definition, 42); + var violationsDouble = service.validatePropertyValue(definition, 42.5); - assertEquals(List.of(), violationsInt); - assertEquals(List.of(), violationsDouble); - } + assertEquals(List.of(), violationsInt); + assertEquals(List.of(), violationsDouble); + } - @Test - @DisplayName("Should return no violations when NUMBER has no rules") - void shouldReturnNoViolationsWhenNumberHasNoRules() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should return no violations when NUMBER is within bounds") - void shouldReturnNoViolationsWhenNumberIsWithinBounds() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minValue violation") - void shouldReportMinValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), + violations); + } - @Test - @DisplayName("Should report maxValue violation") - void shouldReportMaxValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), + violations); + } - @Test - @DisplayName("Should report both minValue and maxValue violations") - void shouldReportBothMinAndMaxViolations() { - // minValue > maxValue edge case — value below min triggers min violation - var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); - var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7"); - // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation - assertEquals(2, violations.size()); - } + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } - @Test - @DisplayName("Should accept decimal number values") - void shouldAcceptDecimalNumberValues() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") - void shouldReportTypeMismatchWhenBooleanSentForNumber() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), + violations); } + } - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { - - @Test - @DisplayName("Should accept raw Boolean objects") - void shouldAcceptRawBooleanObjects() { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - var violationsTrue = service.validatePropertyValue(definition, true); - var violationsFalse = service.validatePropertyValue(definition, Boolean.FALSE); + @Test + @DisplayName("Should accept raw Boolean objects") + void shouldAcceptRawBooleanObjects() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - assertEquals(List.of(), violationsTrue); - assertEquals(List.of(), violationsFalse); - } + var violationsTrue = service.validatePropertyValue(definition, true); + var violationsFalse = service.validatePropertyValue(definition, Boolean.FALSE); - @ParameterizedTest(name = "Should accept valid boolean string value: ''{0}''") - @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) - void shouldAcceptValidBooleanValues(String value) { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + assertEquals(List.of(), violationsTrue); + assertEquals(List.of(), violationsFalse); + } - var violations = service.validatePropertyValue(definition, value); + @ParameterizedTest(name = "Should accept valid boolean string value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - assertEquals(List.of(), violations); - } + var violations = service.validatePropertyValue(definition, value); - @ParameterizedTest(name = "Should report type mismatch for invalid BOOLEAN input: {0}") - @MethodSource("invalidBooleanValues") - void shouldReportTypeMismatchForInvalidBooleanInputs(Object value) { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + assertEquals(List.of(), violations); + } - var violations = service.validatePropertyValue(definition, value); + @ParameterizedTest(name = "Should report type mismatch for invalid BOOLEAN input: {0}") + @MethodSource("invalidBooleanValues") + void shouldReportTypeMismatchForInvalidBooleanInputs(Object value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); - } + var violations = service.validatePropertyValue(definition, value); - private static Stream invalidBooleanValues() { - return Stream.of(null, "yes", "42"); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), + violations); } - private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { - return new PropertyDefinition(null, name, "description", type, true, rules); + private static Stream invalidBooleanValues() { + return Stream.of(null, "yes", "42"); } + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, + PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java index d53ff01..2e4ca80 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java @@ -14,10 +14,10 @@ import java.util.List; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -33,212 +33,196 @@ @ExtendWith(MockitoExtension.class) class RelationValidationServiceTest { - @Mock - private EntityRepositoryPort entityRepository; + @Mock + private EntityRepositoryPort entityRepository; - private RelationValidationService service; + private RelationValidationService service; - @BeforeEach - void setUp() { - service = new RelationValidationService(entityRepository); - } + @BeforeEach + void setUp() { + service = new RelationValidationService(entityRepository); + } - private void mockExistingEntities(String... identifiers) { - var summaries = Arrays.stream(identifiers) - .map(id -> new EntitySummary(id, "Name", "template")) - .toList(); - when(entityRepository.findByIdentifierIn(any())).thenReturn(summaries); - } + private void mockExistingEntities(String... identifiers) { + var summaries = Arrays.stream(identifiers).map(id -> new EntitySummary(id, "Name", "template")) + .toList(); + when(entityRepository.findByIdentifierIn(any())).thenReturn(summaries); + } - @Test - @DisplayName("Should pass all checks cleanly when relations map exactly to definitions") - void shouldPassCleanlyOnValidEntity() { - mockExistingEntities("team-a", "service-x", "service-y"); - var definition1 = definition("owned-by", true, false); - var definition2 = definition("depends-on", false, true); - var template = template("system-template", List.of(definition1, definition2)); + @Test + @DisplayName("Should pass all checks cleanly when relations map exactly to definitions") + void shouldPassCleanlyOnValidEntity() { + mockExistingEntities("team-a", "service-x", "service-y"); + var definition1 = definition("owned-by", true, false); + var definition2 = definition("depends-on", false, true); + var template = template("system-template", List.of(definition1, definition2)); - var relation1 = relation("owned-by", List.of("team-a")); - var relation2 = relation("depends-on", List.of("service-x", "service-y")); + var relation1 = relation("owned-by", List.of("team-a")); + var relation2 = relation("depends-on", List.of("service-x", "service-y")); - var violations = mock(Violations.class); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(relation1, relation2), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation1, relation2), violations); - verifyNoInteractions(violations); - } + verifyNoInteractions(violations); + } - private EntityTemplate template(String identifier, List relationDefinitions) { - return new EntityTemplate( - UUID.randomUUID(), - identifier, - "Template Name", - "Description", - List.of(), - relationDefinitions - ); - } + private EntityTemplate template(String identifier, List relationDefinitions) { + return new EntityTemplate(UUID.randomUUID(), identifier, "Template Name", "Description", + List.of(), relationDefinitions); + } + + private RelationDefinition definition(String name, boolean required, boolean toMany) { + return new RelationDefinition(UUID.randomUUID(), name, "targetType", required, toMany); + } - private RelationDefinition definition(String name, boolean required, boolean toMany) { - return new RelationDefinition( - UUID.randomUUID(), - name, - "targetType", - required, - toMany - ); + private Relation relation(String name, List targets) { + return new Relation(UUID.randomUUID(), name, "targetType", targets); + } + + @Nested + @DisplayName("Relation Existence Checks") + class ExistenceTests { + + @Test + @DisplayName("Should report violation when relation is not defined in the template") + void shouldReportViolationWhenRelationNotDefined() { + var template = template("system-template", List.of()); + var relation = relation("unknown-relation", List.of("target-1")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_NOT_DEFINED_IN_TEMPLATE, "unknown-relation", + "system-template"); } - private Relation relation(String name, List targets) { - return new Relation( - UUID.randomUUID(), - name, - "targetType", - targets - ); + @Test + @DisplayName("Should handle missing definition lists and relation lists gracefully") + void shouldHandleNullListsGracefully() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), null); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, null, violations); + + verifyNoInteractions(violations); } - @Nested - @DisplayName("Relation Existence Checks") - class ExistenceTests { + @Test + @DisplayName("Should report violation when relation target entity does not exist") + void shouldReportViolationWhenRelationTargetEntityDoesNotExist() { + mockExistingEntities("existing-team"); + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("missing-team")); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report violation when relation is not defined in the template") - void shouldReportViolationWhenRelationNotDefined() { - var template = template("system-template", List.of()); - var relation = relation("unknown-relation", List.of("target-1")); - var violations = mock(Violations.class); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + verify(violations).add(RELATION_TARGET_ENTITY_NOT_FOUND, "owned-by", "missing-team"); + } + } - verify(violations).add(RELATION_NOT_DEFINED_IN_TEMPLATE, "unknown-relation", "system-template"); - } + @Nested + @DisplayName("Relation Requirement Checks") + class RequirementTests { - @Test - @DisplayName("Should handle missing definition lists and relation lists gracefully") - void shouldHandleNullListsGracefully() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), null); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required relation is missing completely") + void shouldReportViolationWhenRequiredRelationMissing() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, null, violations); + service.validateRelationsAgainstTemplate(template, List.of(), violations); - verifyNoInteractions(violations); - } + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } - @Test - @DisplayName("Should report violation when relation target entity does not exist") - void shouldReportViolationWhenRelationTargetEntityDoesNotExist() { - mockExistingEntities("existing-team"); - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("missing-team")); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required relation is provided but target list is empty") + void shouldReportViolationWhenRequiredRelationHasEmptyTargets() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of()); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - verify(violations).add(RELATION_TARGET_ENTITY_NOT_FOUND, "owned-by", "missing-team"); - } + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); } - @Nested - @DisplayName("Relation Requirement Checks") - class RequirementTests { + @Test + @DisplayName("Should report violation when required relation only has blank targets") + void shouldReportViolationWhenRequiredRelationHasOnlyBlankTargets() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("", " ")); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report violation when required relation is missing completely") - void shouldReportViolationWhenRequiredRelationMissing() { - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var violations = mock(Violations.class); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - service.validateRelationsAgainstTemplate(template, List.of(), violations); + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } - verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); - } + @Test + @DisplayName("Should not report violation when an optional relation is omitted") + void shouldNotReportViolationWhenOptionalRelationOmitted() { + var definition = definition("depends-on", false, true); + var template = template("system-template", List.of(definition)); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report violation when required relation is provided but target list is empty") - void shouldReportViolationWhenRequiredRelationHasEmptyTargets() { - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of()); - var violations = mock(Violations.class); + service.validateRelationsAgainstTemplate(template, List.of(), violations); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + verifyNoInteractions(violations); + } + } - verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); - } + @Nested + @DisplayName("Relation Cardinality Checks") + class CardinalityTests { - @Test - @DisplayName("Should report violation when required relation only has blank targets") - void shouldReportViolationWhenRequiredRelationHasOnlyBlankTargets() { - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("", " ")); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when a non-toMany relation has multiple valid targets") + void shouldReportViolationForMultipleTargetsOnSingleRelation() { + mockExistingEntities("team-a", "team-b"); + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("team-a", "team-b")); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); - } + verify(violations).add(RELATION_TOO_MANY_TARGETS, "owned-by", "system-template"); + } - @Test - @DisplayName("Should not report violation when an optional relation is omitted") - void shouldNotReportViolationWhenOptionalRelationOmitted() { - var definition = definition("depends-on", false, true); - var template = template("system-template", List.of(definition)); - var violations = mock(Violations.class); + @Test + @DisplayName("Should not report violation for multiple targets if toMany is true") + void shouldNotReportViolationForMultipleTargetsWhenToManyIsTrue() { + mockExistingEntities("service-a", "service-b", "service-c"); + var definition = definition("depends-on", false, true); + var template = template("system-template", List.of(definition)); + var relation = relation("depends-on", List.of("service-a", "service-b", "service-c")); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - verifyNoInteractions(violations); - } + verifyNoInteractions(violations); } - @Nested - @DisplayName("Relation Cardinality Checks") - class CardinalityTests { - - @Test - @DisplayName("Should report violation when a non-toMany relation has multiple valid targets") - void shouldReportViolationForMultipleTargetsOnSingleRelation() { - mockExistingEntities("team-a", "team-b"); - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("team-a", "team-b")); - var violations = mock(Violations.class); - - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - - verify(violations).add(RELATION_TOO_MANY_TARGETS, "owned-by", "system-template"); - } - - @Test - @DisplayName("Should not report violation for multiple targets if toMany is true") - void shouldNotReportViolationForMultipleTargetsWhenToManyIsTrue() { - mockExistingEntities("service-a", "service-b", "service-c"); - var definition = definition("depends-on", false, true); - var template = template("system-template", List.of(definition)); - var relation = relation("depends-on", List.of("service-a", "service-b", "service-c")); - var violations = mock(Violations.class); - - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - - verifyNoInteractions(violations); - } - - @Test - @DisplayName("Should ignore blank targets when checking cardinality constraints") - void shouldIgnoreBlankTargetsForCardinality() { - mockExistingEntities("team-a"); - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("team-a", " ", "")); - var violations = mock(Violations.class); - - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - - verifyNoInteractions(violations); - } + @Test + @DisplayName("Should ignore blank targets when checking cardinality constraints") + void shouldIgnoreBlankTargetsForCardinality() { + mockExistingEntities("team-a"); + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("team-a", " ", "")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index f5aace7..a15741b 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -29,13 +29,13 @@ /// authentication, and lookup by template identifier and entity identifier. public class EntityControllerTest extends AbstractIntegrationTest { - private static final String TEMPLATE_IDENTIFIER = "web-service"; - private static final String ENTITY_IDENTIFIER = "web-api-2"; - private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/{identifier}"; - private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; - private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - @Autowired - private MockMvc mockMvc; + private static final String TEMPLATE_IDENTIFIER = "web-service"; + private static final String ENTITY_IDENTIFIER = "web-api-2"; + private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/{identifier}"; + private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; + private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint /// (paginated retrieval). @@ -43,20 +43,17 @@ public class EntityControllerTest extends AbstractIntegrationTest { @DisplayName("GET /api/v1/entities/{template-identifier} - Get Templates Paginated") class GetEntitiesByTemplateIdentifierTests { - @Test @DisplayName("Should return paginated entities with default pagination") @WithMockUser void getEntities_paginated_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "15").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -68,8 +65,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); } @Test @@ -77,57 +73,52 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @WithMockUser void getEntities_404_nonExistentTemplate_withFilter() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .param("q", "name=foo") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); + .param("q", "name=foo").accept(APPLICATION_JSON)).andExpect(status().isNotFound()); } - @Test @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + mockMvc.perform( + get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } - @Test - @DisplayName("Should return paginated entities with custom pagination") - @WithMockUser - void getEntities_paginated_200_custom() throws Exception { - - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) - .andExpect(jsonPath("$.page.total_elements").value(6)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } + @Test + @DisplayName("Should return paginated entities with custom pagination") + @WithMockUser + void getEntities_paginated_200_custom() throws Exception { + + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("page", "1").param("size", "5").param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); + } - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_invalid_pagination_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); } + } /// Tests for GET /api/v1/entities/{template-identifier}?q= endpoint @Nested @@ -135,92 +126,82 @@ void getEntities_invalid_pagination_200() throws Exception { class GetEntitiesByTemplateIdentifierWithFilterTests { @ParameterizedTest - @CsvSource({ - "identifier=web-api-1", - "name:Web API 1", - "property.programmingLanguage=JAVA", - "relation=api-link", - "relation.api-link.name:microservice", - "relation=api-link;relation.api-link.name:microservice" - }) + @CsvSource({"identifier=web-api-1", "name:Web API 1", "property.programmingLanguage=JAVA", + "relation=api-link", "relation.api-link.name:microservice", + "relation=api-link;relation.api-link.name:microservice"}) @DisplayName("Should filter entities by various criteria") @WithMockUser void getEntities_200_withFilter(String query) throws Exception { - MvcResult mvcResult = mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + MvcResult mvcResult = mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", query) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_identifierEquals.json"), - mvcResult.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_identifierEquals.json"), + mvcResult.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should return empty page when no entity matches filter") @WithMockUser void getEntities_200_noMatch() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "name=nonexistent-entity") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(0)); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "name=nonexistent-entity").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(0)); } @Test @DisplayName("Should filter microservices by relations_as_target identifier") @WithMockUser void getEntities_200_relationsAsTargetIdentifier() throws Exception { - MvcResult mvcResult = mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") - .param("q", "relations_as_target.api-link.identifier=web-api-1") - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + MvcResult mvcResult = mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") + .param("q", "relations_as_target.api-link.identifier=web-api-1") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), - mvcResult.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), + mvcResult.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should filter microservices by relations_as_target name contains") @WithMockUser void getEntities_200_relationsAsTargetNameContains() throws Exception { - MvcResult mvcResult = mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") - .param("q", "relations_as_target.api-link.name:Web API") - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + MvcResult mvcResult = mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") + .param("q", "relations_as_target.api-link.name:Web API").accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), - mvcResult.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), + mvcResult.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should return 400 for malformed query without operator") @WithMockUser void getEntities_400_malformedQuery() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "noOperator") - .accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Invalid query format, expected field:operator:value")); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "noOperator").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("Invalid query format, expected field:operator:value")); } @Test @DisplayName("Should return 400 for duplicate criterion on the same field") @WithMockUser void getEntities_400_duplicateCriterion() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "name=A;name=B") - .accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Multiple filters for the same property are not supported")); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "name=A;name=B").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("Multiple filters for the same property are not supported")); } @Test @@ -229,38 +210,32 @@ void getEntities_400_duplicateCriterion() throws Exception { void getEntities_400_tooManyCriteria() throws Exception { var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;property.k=11"; - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query) - .accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Filter query exceeds maximum of 10 criteria")); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", query) + .accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect( + jsonPath("$.error_description").value("Filter query exceeds maximum of 10 criteria")); } @ParameterizedTest(name = "comparison filter ''{0}'' returns 400") - @CsvSource({ - "nameWeb API 1" - }) + @CsvSource({"nameWeb API 1"}) @DisplayName("Should return 400 when < or > is used on attribute fields") @WithMockUser void getEntities_400_comparisonOnAttribute(String query) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query.trim()) - .accept(APPLICATION_JSON)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", query.trim()).accept(APPLICATION_JSON)) .andExpect(status().isBadRequest()); } @ParameterizedTest(name = "comparison filter ''{0}'' returns 400") - @CsvSource({ - "property.programmingLanguageJAVA" - }) + @CsvSource({"property.programmingLanguageJAVA"}) @DisplayName("Should return 400 when < or > is used on a STRING property") @WithMockUser void getEntities_400_comparisonOnStringProperty(String query) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query.trim()) - .accept(APPLICATION_JSON)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", query.trim()).accept(APPLICATION_JSON)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_description").value( "Operation '%s' is not applicable for property 'programmingLanguage': only NUMBER properties support comparison operators." @@ -268,16 +243,14 @@ void getEntities_400_comparisonOnStringProperty(String query) throws Exception { } @ParameterizedTest(name = "comparison filter ''{0}'' returns {1} result(s)") - @CsvSource({ - "property.port<9090, 1", - "property.port>8080, 1" - }) + @CsvSource({"property.port<9090, 1", "property.port>8080, 1"}) @DisplayName("Should filter entities using < and > comparison operators on a NUMBER property") @WithMockUser - void getEntities_200_comparisonOnNumberProperty(String query, int expectedCount) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query.trim()) - .accept(APPLICATION_JSON)) + void getEntities_200_comparisonOnNumberProperty(String query, int expectedCount) + throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", query.trim()).accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.length()").value(expectedCount)); } @@ -287,25 +260,22 @@ void getEntities_200_comparisonOnNumberProperty(String query, int expectedCount) @DisplayName("Should return all entities when q is empty or blank") @WithMockUser void getEntities_200_emptyOrBlankQ_returnsAllEntities(String q) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", q) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", q) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)); } @Test @DisplayName("Should filter and paginate when q and page/size are combined") @WithMockUser void getEntities_200_paginationWithFilter() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("q", "name:Monitoring") - .param("page", "1") - .param("size", "3") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(3)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("q", "name:Monitoring").param("page", "1").param("size", "3") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(3)) .andExpect(jsonPath("$.page.total_elements").value(6)) .andExpect(jsonPath("$.page.total_pages").value(2)) .andExpect(jsonPath("$.page.number").value(1)); @@ -316,59 +286,57 @@ void getEntities_200_paginationWithFilter() throws Exception { @WithMockUser void getEntities_200_comparisonOperators_areCaseSensitive() throws Exception { // EQUALS normalises both sides to lowercase → case-insensitive match - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "property.programmingLanguage=python") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "property.programmingLanguage=python").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)); // LESS_THAN and GREATER_THAN pass values to the DB without lowercasing. // port is a NUMBER property (8080 for web-api-1, 9090 for web-api-2). - // These assertions verify correct boundary semantics using raw numeric string comparison. - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "property.port>8080") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)); - - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "property.port<9090") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)); + // These assertions verify correct boundary semantics using raw numeric string + // comparison. + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "property.port>8080").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)); + + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "property.port<9090").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)); } @Test @DisplayName("Should return 400 for operator mismatch on criterion type") @WithMockUser void getEntities_400_typeMismatch() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "relation updatedTemplateOpt = entityTemplateRepository + .findByIdentifier("template-rel-test"); + assertThat(updatedTemplateOpt).isPresent(); + + EntityTemplate updatedTemplate = updatedTemplateOpt.get(); + + // Vérifier description mise à jour + assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); + + // Vérifier properties + assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); + assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) + .isEqualTo("Updated description"); + + // Vérifier relations + assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); + + Map relationsMap = updatedTemplate.relationsDefinitions().stream() + .collect(Collectors.toMap(RelationDefinition::name, r -> r)); + + assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); + assertThat(relationsMap.get("owns").required()).isFalse(); + assertThat(relationsMap.get("owns").toMany()).isFalse(); + + assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()) + .isEqualTo("database-service"); + assertThat(relationsMap.get("belongsTo").required()).isTrue(); + assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); + } + + @Test + @WithMockUser() + @DisplayName("Should update template and return 201") + void putTemplate_200() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("web-service"); + assertThat(entityTemplateUpdated).isPresent(); + assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); + assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without + /// properties. + /// This test verifies that: + /// - Templates can be updated without any properties + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template without properties and return 200") + void putTemplate_200_without_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform( + MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_without_properties.json"))) + .andExpect(status().isOk()); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty + /// properties array. + /// This test verifies that: + /// - Templates can be updated with an empty properties array + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template with empty properties array and return 200") + void putTemplate_200_with_empty_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_with_empty_properties.json"))) + .andExpect(status().isOk()); + } + @Test + @WithMockUser + void putTemplate_404_withUnknownIdentifier() throws Exception { + String identifier = "unknown-identifier"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isNotFound()).andExpect(content().string( + "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); } - @Nested - @DisplayName("PUT /api/v1/entity-templates - Update Template") - @Order(3) - class PutTemplateTests { - - @Test - void putTemplate_without_user_token_401() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - @DisplayName("Should update existing property rules using PUT") - void putTemplate_shouldMergePropertyRules() throws Exception { - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "postEntityTemplateWithoutRelationsDefinitions_201.json"))) - .andExpect(status().isCreated()); - - EntityTemplate initialTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - PropertyDefinition initialProperty = initialTemplate.propertiesDefinitions().get(0); - UUID initialRulesId = initialProperty.rules().id(); - - mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/entity-templates/temp-test-99") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "putEntityTemplate_updateRules_200.json"))) - .andExpect(status().isOk()); - - EntityTemplate updatedTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - - PropertyDefinition updatedProperty = updatedTemplate.propertiesDefinitions().get(0); - - assertThat(updatedProperty.name()).isEqualTo("property-test"); - - PropertyRules updatedRules = updatedProperty.rules(); - assertThat(updatedRules.format()).isNull(); - assertThat(updatedRules.regex()).isEqualTo("^[a-zA-Z0-9]+$"); - assertThat(updatedRules.maxLength()).isEqualTo(255); - assertThat(updatedRules.minLength()).isNull(); - - assertThat(updatedRules.id()).isEqualTo(initialRulesId); - - assertThat(updatedTemplate.relationsDefinitions()).isEmpty(); - } - - @Test - @WithMockUser - @DisplayName("Should update template with relations and return 200") - void putTemplate_updateRelations_200() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "identifier": "template-rel-test", - "name": "Template Rel Test", - "description": "Initial template", - "properties_definitions": [ - { - "name": "property1", - "description": "description", - "required": true, - "type": "STRING", - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": true, - "to_many": true - } - ] - } - """)) - .andExpect(status().isCreated()); - - String updateJson = """ - { - "name": "Template Rel Test", - "description": "Updated template with new relation", - "properties_definitions": [ - { - "name": "property1", - "description": "Updated description", - "type": "STRING", - "required": true, - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": false, - "to_many": false - }, - { - "name": "belongsTo", - "target_template_identifier": "database-service", - "required": true, - "to_many": false - } - ] - } - """; - - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/template-rel-test") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(updateJson)) - .andExpect(status().isOk()); - - Optional updatedTemplateOpt = entityTemplateRepository - .findByIdentifier("template-rel-test"); - assertThat(updatedTemplateOpt).isPresent(); - - EntityTemplate updatedTemplate = updatedTemplateOpt.get(); - - // Vérifier description mise à jour - assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); - - // Vérifier properties - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) - .isEqualTo("Updated description"); - - // Vérifier relations - assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); - - Map relationsMap = updatedTemplate.relationsDefinitions() - .stream() - .collect(Collectors.toMap(RelationDefinition::name, r -> r)); - - assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); - assertThat(relationsMap.get("owns").required()).isFalse(); - assertThat(relationsMap.get("owns").toMany()).isFalse(); - - assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()).isEqualTo("database-service"); - assertThat(relationsMap.get("belongsTo").required()).isTrue(); - assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); - } - - @Test - @WithMockUser() - @DisplayName("Should update template and return 201") - void putTemplate_200() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("web-service"); - assertThat(entityTemplateUpdated).isPresent(); - assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); - assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without properties. - /// This test verifies that: - /// - Templates can be updated without any properties - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template without properties and return 200") - void putTemplate_200_without_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_without_properties.json"))) - .andExpect(status().isOk()); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty properties array. - /// This test verifies that: - /// - Templates can be updated with an empty properties array - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template with empty properties array and return 200") - void putTemplate_200_with_empty_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_with_empty_properties.json"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void putTemplate_404_withUnknownIdentifier() throws Exception { - String identifier = "unknown-identifier"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isNotFound()) - .andExpect(content().string( - "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyTypeIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); - } - - @Test - @WithMockUser() - void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { - String identifier = "web-service"; - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("microservice"); - assertThat(entityTemplateUpdated).isPresent(); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string( - "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template name mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is missing") - void putTemplate_400_name_missing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// blank. - /// This test verifies that: - /// - Validation error message contains expected template name mandatory message - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is blank") - void putTemplate_400_name_blank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field - /// already exists. - /// This test verifies that: - /// - Validation error message contains expected template name already exists - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when name already exists") - void putTemplate_409_name_already_exists() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string("{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// too long. - /// This test verifies that: - /// - Validation error message matches expected template name too long - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is too long") - void putTemplate_400_name_too_long() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field does - /// not respect regex pattern. - /// This test verifies that: - /// - Validation error message matches expected template name pattern - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name does not respect regex pattern") - void putTemplate_400_name_invalid_pattern() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); - } - - /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects - /// requests with an identifier field in the request body. - /// **This test verifies that:** - /// - The endpoint returns HTTP 400 Bad Request when identifier is in body - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should reject PUT request with identifier in body and return 400") - void putTemplate_400_identifier_in_body() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_400_identifier_in_body.json"))) - .andExpect(status().isBadRequest()); - } - - /// Tests PUT endpoint when attempting to change property type on existing property. - /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing property type") - void putTemplate_400_type_change() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_type_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); - } - - /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an existing relation. - /// Verifies that RelationTargetTemplateChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") - void putTemplate_400_target_template_identifier_change() throws Exception { - String identifier = "microservice"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_target_template_identifier_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value( - containsString("Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); - } + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + @Test + @WithMockUser() + void putTemplate_400_propertyTypeIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); } - @Nested - @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") - @Order(4) - class DeleteTemplateTests { - - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful - /// template deletion. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should delete template and return 204") - void deleteTemplate_204() throws Exception { - // Use an existing template ID from test data - String templateId = "monitoring-service"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - assertNotNull(templateId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does - /// not exist. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should return 404 when template not found") - void deleteTemplate_404_not_found() throws Exception { - // Use a non-existent template ID - String nonExistentId = "non-existing-identifier"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.error").value("NOT_FOUND")) - .andExpect(jsonPath("$.error_description").exists()); - - assertNotNull(nonExistentId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication is missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 401 when deleting without user token") - void deleteTemplate_401_without_user_token() throws Exception { - String templateId = "123e4567-e89b-12d3-a456-426614174001"; - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isUnauthorized()); - - } + @Test + @WithMockUser() + void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { + String identifier = "web-service"; + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("microservice"); + assertThat(entityTemplateUpdated).isPresent(); + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name + /// field is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template name mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is missing") + void putTemplate_400_name_missing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// blank. + /// This test verifies that: + /// - Validation error message contains expected template name mandatory message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is blank") + void putTemplate_400_name_blank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// already exists. + /// This test verifies that: + /// - Validation error message contains expected template name already exists + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when name already exists") + void putTemplate_409_name_already_exists() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// too long. + /// This test verifies that: + /// - Validation error message matches expected template name too long + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is too long") + void putTemplate_400_name_too_long() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// does + /// not respect regex pattern. + /// This test verifies that: + /// - Validation error message matches expected template name pattern + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name does not respect regex pattern") + void putTemplate_400_name_invalid_pattern() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); + } + + /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects + /// requests with an identifier field in the request body. + /// **This test verifies that:** + /// - The endpoint returns HTTP 400 Bad Request when identifier is in body + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should reject PUT request with identifier in body and return 400") + void putTemplate_400_identifier_in_body() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_400_identifier_in_body.json"))) + .andExpect(status().isBadRequest()); + } + + /// Tests PUT endpoint when attempting to change property type on existing + /// property. + /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing property type") + void putTemplate_400_type_change() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_type_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); + } + + /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an + /// existing relation. + /// Verifies that RelationTargetTemplateChangeException is thrown and returns + /// 400 Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") + void putTemplate_400_target_template_identifier_change() throws Exception { + String identifier = "microservice"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_target_template_identifier_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); + } + + } + + @Nested + @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") + @Order(4) + class DeleteTemplateTests { + + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful + /// template deletion. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should delete template and return 204") + void deleteTemplate_204() throws Exception { + // Use an existing template ID from test data + String templateId = "monitoring-service"; + + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + assertNotNull(templateId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does + /// not exist. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should return 404 when template not found") + void deleteTemplate_404_not_found() throws Exception { + // Use a non-existent template ID + String nonExistentId = "non-existing-identifier"; + + mockMvc + .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + + assertNotNull(nonExistentId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication + /// is missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 401 when deleting without user token") + void deleteTemplate_401_without_user_token() throws Exception { + String templateId = "123e4567-e89b-12d3-a456-426614174001"; + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index d44ce9f..8dff1a3 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -10,6 +10,9 @@ import java.util.Set; import java.util.stream.Stream; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -33,9 +36,6 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; - /// Comprehensive unit tests for [ApiExceptionHandler]. /// /// Tests all exception handler methods and utility functions to ensure proper @@ -43,461 +43,476 @@ @DisplayName("ApiExceptionHandler Tests") class ApiExceptionHandlerTest { - private ApiExceptionHandler exceptionHandler; + private ApiExceptionHandler exceptionHandler; + + @BeforeEach + void setUp() throws Exception { + // Use reflection to create instance since constructor is private + Constructor constructor = ApiExceptionHandler.class + .getDeclaredConstructor(); + constructor.setAccessible(true); + exceptionHandler = constructor.newInstance(); + } + + @Nested + @DisplayName("Domain Exception Handling") + class DomainExceptionTests { + + /// Tests the handling of [EntityTemplateNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the correct error status and description + /// - Original exception message is preserved in the response + @Test + @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") + void shouldHandleEntityTemplateNotFoundException() { + // Given + String errorMessage = "Template with ID 'test-id' not found"; + EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); + + // When + ResponseEntity response = exceptionHandler + .handleTemplateNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(errorMessage, body.getErrorDescription()); + } + + /// Tests the handling of [EntityTemplateAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and formatted description + /// - Exception message is properly formatted with validation constants + @Test + @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateAlreadyExistsException() { + // Given + String identifier = "duplicate-id"; + EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException( + identifier); + String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(expectedMessage, body.getErrorDescription()); + } - @BeforeEach - void setUp() throws Exception { - // Use reflection to create instance since constructor is private - Constructor constructor = ApiExceptionHandler.class.getDeclaredConstructor(); - constructor.setAccessible(true); - exceptionHandler = constructor.newInstance(); + /// Tests the handling of [EntityAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", + "api-gateway"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Domain Exception Handling") - class DomainExceptionTests { - - /// Tests the handling of [EntityTemplateNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the correct error status and description - /// - Original exception message is preserved in the response - @Test - @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") - void shouldHandleEntityTemplateNotFoundException() { - // Given - String errorMessage = "Template with ID 'test-id' not found"; - EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); - - // When - ResponseEntity response = exceptionHandler.handleTemplateNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(errorMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and formatted description - /// - Exception message is properly formatted with validation constants - @Test - @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateAlreadyExistsException() { - // Given - String identifier = "duplicate-id"; - EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException(identifier); - String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(expectedMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the original domain exception message - @Test - @DisplayName("Should handle EntityAlreadyExistsException with 409 status") - void shouldHandleEntityAlreadyExistsException() { - // Given - EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); - - // When - ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle EntityValidationException with 400 status") - void shouldHandleEntityValidationException() { - EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); - - ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNameAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and description - @Test - @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateNameAlreadyExistsException() { - // Given - String name = "Duplicate Name"; - EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateNameAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the entity-specific context message - @Test - @DisplayName("Should handle EntityNotFoundException with 404 status") - void shouldHandleEntityNotFoundException() { - // Given - EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); - - // When - ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException( + java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler + .handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single - /// validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation("Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); - } - - /// Tests the handling of [ConstraintViolationException] with multiple - /// validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation("Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation("Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } - - /// Tests the handling of [MethodArgumentNotValidException] with field - /// validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma - /// separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, - bindingResult); - - // When - ResponseEntity response = exceptionHandler.handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } - - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } - - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException( + name); + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Validation Exception Handling") + class ValidationExceptionTests { + + /// Tests the handling of [ConstraintViolationException] with a single + /// validation violation. + /// + /// **This test verifies that:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); } - @Nested - @DisplayName("HTTP Message Exception Handling") - class HttpMessageExceptionTests { - - /// Provides test data for [HttpMessageNotReadableException] scenarios. Each - /// argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required"), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body"), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'"), - Arguments.of( - "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_FORMAT' for property 'format'"), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body"), - Arguments.of( - "Cannot deserialize value of type `com.example.SomeType`: some other error", - "Invalid type: expected SomeType"), - Arguments.of( - "Something completely unexpected happened", - "Invalid request body format"), - Arguments.of( - "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", - "Invalid value for property 'type'")); - } - - /// Tests the handling of [HttpMessageNotReadableException] when exception - /// message is null. - /// - /// **This test verifies that:** - /// - HttpMessageNotReadableException with null message is properly handled - /// - HTTP 400 Bad Request status is returned - /// - Default error message is provided when original message is null - /// - Graceful handling of edge case scenarios - @Test - @DisplayName("Should handle HttpMessageNotReadableException with null message") - void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(null); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Invalid request body format", body.getErrorDescription()); - } - - /// Parameterized test for handling [HttpMessageNotReadableException] with - /// various error scenarios. - /// - /// **This test verifies that different types of HttpMessageNotReadableException - /// are properly parsed and converted to user-friendly error messages:** - /// - Missing request body errors → "Request body is required" - /// - JSON parse errors → "Invalid JSON format in request body" - /// - PropertyType enum deserialization errors → Specific property and value - /// information - /// - Unknown enum deserialization errors → Generic enum error message - /// - /// **Each test case validates that:** - /// - HTTP 400 Bad Request status is returned - /// - Original complex error message is parsed and simplified - /// - User-friendly error description is provided - /// - Error response structure is consistent - /// - /// @param originalMessage the original exception message to be - /// processed - /// @param expectedErrorDescription the expected user-friendly error description - @ParameterizedTest - @MethodSource("httpMessageNotReadableExceptionTestData") - @DisplayName("Should handle HttpMessageNotReadableException with various error types") - void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, - String expectedErrorDescription) { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(originalMessage); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(expectedErrorDescription, body.getErrorDescription()); - } + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); } - @Nested - @DisplayName("Generic Exception Handling") - class GenericExceptionTests { - - /// Tests the handling of generic Exception as a fallback mechanism. - /// - /// **This test verifies that:** - /// - Unexpected exceptions are caught by the generic handler - /// - HTTP 500 Internal Server Error status is returned - /// - Generic error message is provided to avoid exposing internal details - /// - Exception is properly logged for debugging purposes - @Test - @DisplayName("Should handle generic Exception with 500 status") - void shouldHandleGenericException() { - // Given - Exception exception = new RuntimeException("Unexpected error"); - - // When - ResponseEntity response = exceptionHandler.handleGenericException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); - assertEquals("An unexpected error occurred. Please try again later.", body.getErrorDescription()); - } + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } + } + + @Nested + @DisplayName("HTTP Message Exception Handling") + class HttpMessageExceptionTests { + + /// Provides test data for [HttpMessageNotReadableException] scenarios. Each + /// argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of("Required request body is missing: public ResponseEntity", + "Request body is required"), + Arguments.of("JSON parse error: Unexpected character", + "Invalid JSON format in request body"), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'"), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'"), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body"), + Arguments.of("Cannot deserialize value of type `com.example.SomeType`: some other error", + "Invalid type: expected SomeType"), + Arguments.of("Something completely unexpected happened", "Invalid request body format"), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'")); + } + + /// Tests the handling of [HttpMessageNotReadableException] when exception + /// message is null. + /// + /// **This test verifies that:** + /// - HttpMessageNotReadableException with null message is properly handled + /// - HTTP 400 Bad Request status is returned + /// - Default error message is provided when original message is null + /// - Graceful handling of edge case scenarios + @Test + @DisplayName("Should handle HttpMessageNotReadableException with null message") + void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(null); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Invalid request body format", body.getErrorDescription()); + } + + /// Parameterized test for handling [HttpMessageNotReadableException] with + /// various error scenarios. + /// + /// **This test verifies that different types of HttpMessageNotReadableException + /// are properly parsed and converted to user-friendly error messages:** + /// - Missing request body errors → "Request body is required" + /// - JSON parse errors → "Invalid JSON format in request body" + /// - PropertyType enum deserialization errors → Specific property and value + /// information + /// - Unknown enum deserialization errors → Generic enum error message + /// + /// **Each test case validates that:** + /// - HTTP 400 Bad Request status is returned + /// - Original complex error message is parsed and simplified + /// - User-friendly error description is provided + /// - Error response structure is consistent + /// + /// @param originalMessage the original exception message to be + /// processed + /// @param expectedErrorDescription the expected user-friendly error description + @ParameterizedTest + @MethodSource("httpMessageNotReadableExceptionTestData") + @DisplayName("Should handle HttpMessageNotReadableException with various error types") + void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, + String expectedErrorDescription) { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(originalMessage); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(expectedErrorDescription, body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Generic Exception Handling") + class GenericExceptionTests { + + /// Tests the handling of generic Exception as a fallback mechanism. + /// + /// **This test verifies that:** + /// - Unexpected exceptions are caught by the generic handler + /// - HTTP 500 Internal Server Error status is returned + /// - Generic error message is provided to avoid exposing internal details + /// - Exception is properly logged for debugging purposes + @Test + @DisplayName("Should handle generic Exception with 500 status") + void shouldHandleGenericException() { + // Given + Exception exception = new RuntimeException("Unexpected error"); + + // When + ResponseEntity response = exceptionHandler.handleGenericException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); + assertEquals("An unexpected error occurred. Please try again later.", + body.getErrorDescription()); + } + } + + @Nested + @DisplayName("ErrorResponse Class Tests") + class ErrorResponseTests { + + /// Tests the creation of [ErrorResponse] using the all-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated with HttpStatus and description + /// - All fields are properly initialized with provided values + /// - Getter methods return the expected values + /// - Object is successfully created and accessible + @Test + @DisplayName("Should create ErrorResponse with all args constructor") + void shouldCreateErrorResponseWithAllArgsConstructor() { + // Given + HttpStatus status = HttpStatus.BAD_REQUEST; + String description = "Test error message"; + + // When + ErrorResponse errorResponse = new ErrorResponse(status.name(), description); + + // Then + assertNotNull(errorResponse); + assertEquals(status.name(), errorResponse.getError()); + assertEquals(description, errorResponse.getErrorDescription()); } - @Nested - @DisplayName("ErrorResponse Class Tests") - class ErrorResponseTests { - - /// Tests the creation of [ErrorResponse] using the all-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated with HttpStatus and description - /// - All fields are properly initialized with provided values - /// - Getter methods return the expected values - /// - Object is successfully created and accessible - @Test - @DisplayName("Should create ErrorResponse with all args constructor") - void shouldCreateErrorResponseWithAllArgsConstructor() { - // Given - HttpStatus status = HttpStatus.BAD_REQUEST; - String description = "Test error message"; - - // When - ErrorResponse errorResponse = new ErrorResponse(status.name(), description); - - // Then - assertNotNull(errorResponse); - assertEquals(status.name(), errorResponse.getError()); - assertEquals(description, errorResponse.getErrorDescription()); - } - - /// Tests the creation of [ErrorResponse] using the no-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated without parameters - /// - Object is successfully created with default/null field values - /// - Constructor works with `@NoArgsConstructor(force = true)` annotation - /// - Provides flexibility for frameworks requiring default constructors - @Test - @DisplayName("Should create ErrorResponse with no args constructor") - void shouldCreateErrorResponseWithNoArgsConstructor() { - ErrorResponse errorResponse = new ErrorResponse(); - assertNotNull(errorResponse); - } + /// Tests the creation of [ErrorResponse] using the no-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated without parameters + /// - Object is successfully created with default/null field values + /// - Constructor works with `@NoArgsConstructor(force = true)` annotation + /// - Provides flexibility for frameworks requiring default constructors + @Test + @DisplayName("Should create ErrorResponse with no args constructor") + void shouldCreateErrorResponseWithNoArgsConstructor() { + ErrorResponse errorResponse = new ErrorResponse(); + assertNotNull(errorResponse); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java index d5fb800..af67c17 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java @@ -17,82 +17,70 @@ @DisplayName("EntityDtoInMapper Tests") class EntityDtoInMapperTest { - private EntityDtoInMapper mapper; - - @BeforeEach - void setUp() { - mapper = new EntityDtoInMapper(); - } - - @Test - @DisplayName("Should map create DTO to Entity with populated properties and relations") - void shouldMapCreateDtoToEntity() { - // Given - var properties = new LinkedHashMap(); - properties.put("environment", "prod"); - properties.put("port", "8080"); - - var relation = EntityDtoInCommonFields.RelationDtoIn.builder() - .name("depends-on") - .targetEntityIdentifiers(List.of("gateway", "database")) - .build(); - - var commonFields = EntityDtoInCommonFields.builder() - .name("payment-service") - .properties(properties) - .relations(List.of(relation)) - .build(); - - var createDto = EntityCreateDtoIn.builder() - .identifier("payment-service-1") - .entityDtoInCommonFields(commonFields) - .build(); - - // When - Entity result = mapper.fromPostEntityDtoInToEntity(createDto, "service-template"); - - // Then - assertThat(result.id()).isNull(); - assertThat(result.templateIdentifier()).isEqualTo("service-template"); - assertThat(result.name()).isEqualTo("payment-service"); - assertThat(result.identifier()).isEqualTo("payment-service-1"); - - assertThat(result.properties()) - .hasSize(2) - .extracting(property -> property.name() + "=" + property.value()) - .containsExactly("environment=prod", "port=8080"); - - assertThat(result.relations()).hasSize(1); - var mappedRelation = result.relations().getFirst(); - assertThat(mappedRelation.id()).isNull(); - assertThat(mappedRelation.name()).isEqualTo("depends-on"); - assertThat(mappedRelation.targetTemplateIdentifier()).isNull(); - assertThat(mappedRelation.targetEntityIdentifiers()).containsExactly("gateway", "database"); - } - - @Test - @DisplayName("Should map update DTO using path identifier and handle null collections") - void shouldMapUpdateDtoToEntityWithNullCollections() { - // Given - var commonFields = EntityDtoInCommonFields.builder() - .name("catalog-service") - .properties(null) - .relations(null) - .build(); - - var updateDto = EntityUpdateDtoIn.builder() - .entityDtoInCommonFields(commonFields) - .build(); - - // When - Entity result = mapper.fromPutEntityDtoInToEntity(updateDto, "service-template", "catalog-service-42"); - - // Then - assertThat(result.id()).isNull(); - assertThat(result.templateIdentifier()).isEqualTo("service-template"); - assertThat(result.name()).isEqualTo("catalog-service"); - assertThat(result.identifier()).isEqualTo("catalog-service-42"); - assertThat(result.properties()).isEmpty(); - assertThat(result.relations()).isEmpty(); - } + private EntityDtoInMapper mapper; + + @BeforeEach + void setUp() { + mapper = new EntityDtoInMapper(); + } + + @Test + @DisplayName("Should map create DTO to Entity with populated properties and relations") + void shouldMapCreateDtoToEntity() { + // Given + var properties = new LinkedHashMap(); + properties.put("environment", "prod"); + properties.put("port", "8080"); + + var relation = EntityDtoInCommonFields.RelationDtoIn.builder().name("depends-on") + .targetEntityIdentifiers(List.of("gateway", "database")).build(); + + var commonFields = EntityDtoInCommonFields.builder().name("payment-service") + .properties(properties).relations(List.of(relation)).build(); + + var createDto = EntityCreateDtoIn.builder().identifier("payment-service-1") + .entityDtoInCommonFields(commonFields).build(); + + // When + Entity result = mapper.fromPostEntityDtoInToEntity(createDto, "service-template"); + + // Then + assertThat(result.id()).isNull(); + assertThat(result.templateIdentifier()).isEqualTo("service-template"); + assertThat(result.name()).isEqualTo("payment-service"); + assertThat(result.identifier()).isEqualTo("payment-service-1"); + + assertThat(result.properties()).hasSize(2) + .extracting(property -> property.name() + "=" + property.value()) + .containsExactly("environment=prod", "port=8080"); + + assertThat(result.relations()).hasSize(1); + var mappedRelation = result.relations().getFirst(); + assertThat(mappedRelation.id()).isNull(); + assertThat(mappedRelation.name()).isEqualTo("depends-on"); + assertThat(mappedRelation.targetTemplateIdentifier()).isNull(); + assertThat(mappedRelation.targetEntityIdentifiers()).containsExactly("gateway", "database"); + } + + @Test + @DisplayName("Should map update DTO using path identifier and handle null collections") + void shouldMapUpdateDtoToEntityWithNullCollections() { + // Given + var commonFields = EntityDtoInCommonFields.builder().name("catalog-service").properties(null) + .relations(null).build(); + + var updateDto = EntityUpdateDtoIn.builder().entityDtoInCommonFields(commonFields).build(); + + // When + Entity result = mapper.fromPutEntityDtoInToEntity(updateDto, "service-template", + "catalog-service-42"); + + // Then + assertThat(result.id()).isNull(); + assertThat(result.templateIdentifier()).isEqualTo("service-template"); + assertThat(result.name()).isEqualTo("catalog-service"); + assertThat(result.identifier()).isEqualTo("catalog-service-42"); + assertThat(result.properties()).isEmpty(); + assertThat(result.relations()).isEmpty(); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java index a72505d..68a2fa9 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java @@ -19,56 +19,51 @@ @SuppressWarnings("java:S2187") class EntitySpecificationTest { - @Nested - @DisplayName("escapeLikeWildcards") - class EscapeLikeWildcardsTests { + @Nested + @DisplayName("escapeLikeWildcards") + class EscapeLikeWildcardsTests { - @Test - @DisplayName("escapes percent sign") - void escapes_percent() { - assertThat(EntitySpecification.escapeLikeWildcards("100%")) - .isEqualTo("100\\%"); - } + @Test + @DisplayName("escapes percent sign") + void escapes_percent() { + assertThat(EntitySpecification.escapeLikeWildcards("100%")).isEqualTo("100\\%"); + } - @Test - @DisplayName("escapes underscore") - void escapes_underscore() { - assertThat(EntitySpecification.escapeLikeWildcards("my_value")) - .isEqualTo("my\\_value"); - } + @Test + @DisplayName("escapes underscore") + void escapes_underscore() { + assertThat(EntitySpecification.escapeLikeWildcards("my_value")).isEqualTo("my\\_value"); + } - @Test - @DisplayName("escapes backslash before other wildcards") - void escapes_backslash() { - assertThat(EntitySpecification.escapeLikeWildcards("path\\to%file")) - .isEqualTo("path\\\\to\\%file"); - } + @Test + @DisplayName("escapes backslash before other wildcards") + void escapes_backslash() { + assertThat(EntitySpecification.escapeLikeWildcards("path\\to%file")) + .isEqualTo("path\\\\to\\%file"); + } - @Test - @DisplayName("escapes multiple wildcards") - void escapes_multipleWildcards() { - assertThat(EntitySpecification.escapeLikeWildcards("100%_success")) - .isEqualTo("100\\%\\_success"); - } + @Test + @DisplayName("escapes multiple wildcards") + void escapes_multipleWildcards() { + assertThat(EntitySpecification.escapeLikeWildcards("100%_success")) + .isEqualTo("100\\%\\_success"); + } - @Test - @DisplayName("returns plain string unchanged") - void leaves_plainString_unchanged() { - assertThat(EntitySpecification.escapeLikeWildcards("hello")) - .isEqualTo("hello"); - } + @Test + @DisplayName("returns plain string unchanged") + void leaves_plainString_unchanged() { + assertThat(EntitySpecification.escapeLikeWildcards("hello")).isEqualTo("hello"); + } - @ParameterizedTest(name = "escapes ''{0}'' correctly") - @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) - @DisplayName("escapes various wildcard combinations") - void escapes_wildcardCombinations(String input) { - String escaped = EntitySpecification.escapeLikeWildcards(input); - // Strip all valid escape sequences, then verify no bare wildcards remain - String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); - assertThat(stripped) - .doesNotContain("%") - .doesNotContain("_"); - assertThat(escaped).contains("\\"); - } + @ParameterizedTest(name = "escapes ''{0}'' correctly") + @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) + @DisplayName("escapes various wildcard combinations") + void escapes_wildcardCombinations(String input) { + String escaped = EntitySpecification.escapeLikeWildcards(input); + // Strip all valid escape sequences, then verify no bare wildcards remain + String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); + assertThat(stripped).doesNotContain("%").doesNotContain("_"); + assertThat(escaped).contains("\\"); } + } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 3225589..1d954be 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -286,102 +286,3 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis --- ----------------------------------------------------------------------- --- Sample entity instances --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), - ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), - ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), - ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), - ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), - ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), - ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), - ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation - --- ----------------------------------------------------------------------- --- Property data for graph test entities (used by the property-filter tests). --- --- Each graph entity gets two properties: "tier" and "version". --- This lets us verify: --- 1. No filter → both properties appear in node data --- 2. Filter "tier" → only tier present, version absent --- 3. Filter "tier"+"version" → both present --- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) --- ----------------------------------------------------------------------- - -INSERT INTO property (id, name, value) -VALUES - -- graph-svc-a - ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), - ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), - -- graph-svc-b - ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), - ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), - -- graph-svc-c - ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), - ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); - -INSERT INTO entity_properties (entity_id, property_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 4147732..8b33483 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,5 +1,8 @@ --- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), @@ -17,59 +20,146 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); --- Properties for web-api-1 (language=JAVA, environment=PROD) -INSERT INTO idp_core.property (id, name, value) + +-- Add to end of R__1_Insert_test_data.sql + +-- ----------------------------------------------------------------------- +-- Properties for query filter tests (web-api-1 and web-api-2) +-- ----------------------------------------------------------------------- + +-- Properties for web-api-1 (programmingLanguage=JAVA, environment=PROD, port=8080) +INSERT INTO property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000001', 'programmingLanguage', 'JAVA'), ('aa000000-0000-0000-0000-000000000002', 'environment', 'PROD'), ('aa000000-0000-0000-0000-000000000005', 'port', '8080'); -INSERT INTO idp_core.entity_properties (entity_id, property_id) + +INSERT INTO entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000001'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000002'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000005'); --- Properties for web-api-2 (language=PYTHON, environment=DEV) -INSERT INTO idp_core.property (id, name, value) +-- Properties for web-api-2 (programmingLanguage=PYTHON, environment=DEV, port=9090) +INSERT INTO property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000003', 'programmingLanguage', 'PYTHON'), ('aa000000-0000-0000-0000-000000000004', 'environment', 'DEV'), ('aa000000-0000-0000-0000-000000000006', 'port', '9090'); -INSERT INTO idp_core.entity_properties (entity_id, property_id) + +INSERT INTO entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000003'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000004'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000006'); --- Relations for web-api-1 (database -> database-service, targetTemplateIdentifier = database-service) -INSERT INTO idp_core.relation (id, name, target_template_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) +-- ----------------------------------------------------------------------- +-- Relations for query filter tests (web-api-1 and web-api-2) +-- ----------------------------------------------------------------------- + +-- database relation for web-api-1 → database-service-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + +-- database relation for web-api-2 → cache-service-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + +-- api-link relation for web-api-1 → microservice-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); --- Relations for web-api-2 (database -> cache-service, targetTemplateIdentifier = cache-service) -INSERT INTO idp_core.relation (id, name, target_template_identifier) +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) VALUES - ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) VALUES - ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c --- api-link relation for web-api-1 targeting microservice-1 (supports q=relation=api-link;relation.api-link.name:microservice) -INSERT INTO idp_core.relation (id, name, target_template_identifier) +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) VALUES - ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) VALUES - ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file From cfc1c51304da0fc0253baeca990a0671235dd9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 11:47:32 +0200 Subject: [PATCH 24/27] feat(core): add a entity graph service and endpoint --- .github/instructions/domain.instructions.md | 140 +++++++- .gitignore | 6 - .mvn/wrapper/maven-wrapper.properties | 2 + .pre-commit-config.yaml | 18 +- .spotless/eclipse-formatter.xml | 2 +- mvnw | 316 ++++++++++++++++++ mvnw.cmd | 188 +++++++++++ .../entity/EntityValidationException.java | 2 +- .../entity_graph/EntityGraphService.java | 4 + .../mapper/EntityPersistenceMapper.java | 2 - .../db/test/R__1_Insert_test_data.sql | 1 - .../test/R__2_Insert_entities_test_data.sql | 2 +- 12 files changed, 660 insertions(+), 23 deletions(-) create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100755 mvnw create mode 100644 mvnw.cmd diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index 78af57f..3d97903 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -37,10 +37,68 @@ applyTo: '**/domain/**/*.java' ## Exceptions -- Create specific unchecked exceptions for business rule violations (for example, `EntityTemplateNotFoundException`, `EntityTemplateAlreadyExistsException`). +### General Rules + +- Create **specific unchecked exceptions** for each business rule violation (for example, `EntityTemplateNotFoundException`, `EntityAlreadyExistsException`). - Domain exceptions must **not** contain HTTP status codes or REST-specific information. - Map domain exceptions to HTTP status codes exclusively in the Infrastructure layer (`@ControllerAdvice`). +### Exception Clarity + +- **Always prefer specific exceptions over generic ones**. Never throw `IllegalArgumentException` or `IllegalStateException` for business rule violations. +- Exception names must describe **what went wrong** from a business perspective (for example, `EntityTemplateNotFoundException`, not `TemplateException`). +- Exception messages must include **context**: what entity, what identifier, what operation was attempted. + +### Validation Service Pattern + +When a service method needs to validate preconditions (for example, "entity template must exist before creating entity"): + +1. **Extract validation into a dedicated service** (for example, `EntityTemplateValidationService`) +2. **Use explicit method names** that describe the validation (for example, `validateTemplateExists`, `validateTemplateNotExists`) +3. **Throw specific exceptions** that carry business meaning (for example, `EntityTemplateNotFoundException`) +4. **Call validation first** (fail-fast) before executing the main operation + +**Benefits:** + +- **Clear error messages**: `EntityTemplateNotFoundException("web-service")` vs generic `IllegalArgumentException("Invalid template")` +- **Better HTTP mapping**: specific exceptions map to appropriate status codes (404 for not found, 409 for conflict) +- **Reusable validation**: multiple services can call `validateTemplateExists` without duplicating logic +- **Fail-fast**: validation happens before expensive operations (database queries, graph traversal) + +### Exception Naming Convention + +| Pattern | Example | When to Use | +| --------------------------------- | --------------------------------------- | ------------------------------ | +| `NotFoundException` | `EntityTemplateNotFoundException` | Resource doesn't exist (404) | +| `AlreadyExistsException` | `EntityTemplateAlreadyExistsException` | Duplicate key violation (409) | +| `ValidationException` | `PropertyValidationException` | Business rule violation (400) | +| `NotAllowedException` | `EntityDeletionNotAllowedException` | Operation forbidden (403/409) | + +### Exception Structure + +```java +public class EntityTemplateNotFoundException extends RuntimeException { + + private final String identifier; + + public EntityTemplateNotFoundException(String identifier) { + super(String.format("Entity template with identifier '%s' not found", identifier)); + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } +} +``` + +**Rules:** + +- Extend `RuntimeException` (unchecked) for business exceptions +- Include a formatted message with all relevant context +- Store identifiers/keys as fields if needed for logging or error responses +- Never include stack traces in exception messages + ## Constants - Use a dedicated constants class (for example, `ValidationMessages.java`) for all validation messages. @@ -58,6 +116,71 @@ applyTo: '**/domain/**/*.java' - **Adapter-Level vs. Domain-Level**: syntactic checks (nulls, empty strings) belong on DTOs in the Infrastructure layer. Semantic checks (uniqueness, cross-field rules) belong in Domain Services. - Throw a custom `DomainValidationException` (or similar unchecked exception) when rules are violated. +### Creating Validation Services + +When validation logic is reused across multiple domain services: + +1. **Create a dedicated validation service** (for example, `EntityTemplateValidationService`) +2. **Extract validation methods** with clear names: `validateTemplateExists`, `validateTemplateNotExists`, `validateTemplateNotReferenced` +3. **Always call validation first** before the main operation (fail-fast principle) + +**Example validation service:** + +```java +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort repository; + + public void validateTemplateExists(String identifier) { + if (!repository.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException(identifier); + } + } + + public void validateTemplateNotExists(String identifier) { + if (repository.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + public void validateTemplateNotReferenced(String identifier) { + if (repository.hasEntities(identifier)) { + throw new EntityTemplateReferencedException(identifier, + "Cannot delete template that is referenced by entities"); + } + } +} +``` + +**Usage (fail-fast):** + +```java +@Service +@RequiredArgsConstructor +public class EntityService { + + private final EntityTemplateValidationService templateValidation; + private final EntityRepositoryPort entityRepository; + + @Transactional + public Entity createEntity(String templateIdentifier, String entityIdentifier, ...) { + // Validate template exists FIRST (fail-fast) + templateValidation.validateTemplateExists(templateIdentifier); + + // Validate entity doesn't already exist + if (entityRepository.existsByIdentifier(entityIdentifier)) { + throw new EntityAlreadyExistsException(entityIdentifier); + } + + // Main operation + Entity entity = new Entity(...); + return entityRepository.save(entity); + } +} +``` + ## Mapping - Never use `ObjectMapper` or reflection-based libraries for internal layer mapping. @@ -70,10 +193,23 @@ applyTo: '**/domain/**/*.java' domain/ ├── constant/ # Validation message constants ├── exception/ # Domain-specific exceptions +│ ├── entity/ # Entity-related exceptions +│ ├── entity_template/ # Template-related exceptions +│ ├── property/ # Property-related exceptions +│ └── webhook/ # Webhook-related exceptions ├── model/ │ ├── entity/ # Core business records │ ├── entity_template/ # Template records │ └── enums/ # Business enums -├── port/ # Port interfaces (contracts for driven adapters) +├── port/ # Port interfaces (contracts for driven adapters) └── service/ # Domain services (orchestration) + ├── entity/ # Entity services + ├── entity_template/ # Template validation services + └── entity_graph/ # Graph services ``` + +### Exception Package Organization + +- Organize exceptions by aggregate/subdomain (for example, `entity/`, `entity_template/`, `property/`) +- Each exception class should have a clear, descriptive name that follows the naming conventions above +- Keep exception hierarchy flat — avoid deep inheritance trees diff --git a/.gitignore b/.gitignore index 04557c3..16ef5f5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,12 +35,6 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties -# https://github.com/takari/maven-wrapper#usage-without-binary-jar -.mvn/wrapper/maven-wrapper.jar -.mvn/wrapper/maven-wrapper.properties -mvnw -mvnw.cmd - # Eclipse m2e generated files # Eclipse Core diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..7b63acc --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 815a5e2..dd14a01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,15 @@ repos: - --config - .markdownlint.yaml - . - - repo: https://github.com/errata-ai/vale - rev: v3.12.0 - hooks: - - id: vale - name: vale sync - pass_filenames: false - args: [sync] - - id: vale - args: [--output=line, --minAlertLevel=error] + # - repo: https://github.com/errata-ai/vale + # rev: v3.12.0 + # hooks: + # - id: vale + # name: vale sync + # pass_filenames: false + # args: [sync] + # - id: vale + # args: [--output=line, --minAlertLevel=error] - repo: local hooks: - id: spotless-check diff --git a/.spotless/eclipse-formatter.xml b/.spotless/eclipse-formatter.xml index 75b546e..c4da870 100644 --- a/.spotless/eclipse-formatter.xml +++ b/.spotless/eclipse-formatter.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..0e8383c --- /dev/null +++ b/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found $BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find $BASE_DIR/.mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVACMD" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVACMD" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CMD_LINE_ARGS diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..db915c9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_ERROR_CODE%"=="0" exit /b %ERROR_CODE% + +exit /b %ERROR_CODE% diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index 0038120..26859a5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -26,7 +26,7 @@ public class EntityValidationException extends RuntimeException { /** * -- GETTER -- Returns the list of individual validation violation messages. * /// /// - * + * * @return immutable list of violation messages */ private final List violations; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 121145e..a260b6f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -19,6 +19,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import lombok.RequiredArgsConstructor; @@ -49,6 +50,7 @@ public class EntityGraphService { private final EntityRepositoryPort entityRepositoryPort; private final EntityGraphRepositoryPort entityGraphRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; /// Builds the relationship graph for an entity starting from its composite key. /// @@ -64,6 +66,8 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId int depth, boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index ba0e8eb..40dbea1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,9 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 1d954be..2bc5459 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -285,4 +285,3 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis - diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 8b33483..01dbafd 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -162,4 +162,4 @@ VALUES ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version From 984db208752424768665995ba3db8a79a8aefca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 14:48:29 +0200 Subject: [PATCH 25/27] feat(core): add a entity graph service and endpoint --- .github/instructions/domain.instructions.md | 3 +- .mvn/wrapper/maven-wrapper.properties | 2 +- .pre-commit-config.yaml | 18 +- .../entity_graph/EntityGraphService.java | 72 ++++++-- .../api/controller/EntityGraphController.java | 4 +- .../entity/EntityGraphFlatDtoOutMapper.java | 88 +++------ .../entity_graph/EntityGraphServiceTest.java | 168 +++++++++++++++--- 7 files changed, 229 insertions(+), 126 deletions(-) diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index 3d97903..e1f34a6 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -195,8 +195,7 @@ domain/ ├── exception/ # Domain-specific exceptions │ ├── entity/ # Entity-related exceptions │ ├── entity_template/ # Template-related exceptions -│ ├── property/ # Property-related exceptions -│ └── webhook/ # Webhook-related exceptions +│ └── property/ # Property-related exceptions│ ├── model/ │ ├── entity/ # Core business records │ ├── entity_template/ # Template records diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 7b63acc..308007b 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,2 +1,2 @@ distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar \ No newline at end of file +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd14a01..815a5e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,15 @@ repos: - --config - .markdownlint.yaml - . - # - repo: https://github.com/errata-ai/vale - # rev: v3.12.0 - # hooks: - # - id: vale - # name: vale sync - # pass_filenames: false - # args: [sync] - # - id: vale - # args: [--output=line, --minAlertLevel=error] + - repo: https://github.com/errata-ai/vale + rev: v3.12.0 + hooks: + - id: vale + name: vale sync + pass_filenames: false + args: [sync] + - id: vale + args: [--output=line, --minAlertLevel=error] - repo: local hooks: - id: spotless-check diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index a260b6f..b04d1ef 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -40,8 +40,9 @@ /// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, /// inbound relation scanning would re-expand already-visited nodes at every depth /// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). -/// - The service always returns the full unfiltered graph tree. Relation name filtering -/// is a presentation concern applied by the mapper layer. +/// - Relation and property filtering are domain concerns applied during graph construction, +/// so that callers (e.g. the REST controller) receive a graph that already respects +/// the requested scope instead of carrying unnecessary data to the Infrastructure layer. @Service @RequiredArgsConstructor public class EntityGraphService { @@ -54,16 +55,28 @@ public class EntityGraphService { /// Builds the relationship graph for an entity starting from its composite key. /// + /// Relation and property filtering are applied here in the domain layer so that + /// callers receive a correctly scoped graph without needing to know about + /// filtering + /// logic. + /// /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) /// @param includeProperties when true, each graph node carries the entity's - /// full property list - /// @return the root graph node with all resolved relations + /// full property list (subject to propertyFilter) + /// @param relationFilter when non-empty, only relations whose name is in this + /// set are included in the graph; an empty set means no filter — all relations + /// are included + /// @param propertyFilter when non-empty, each node's property list is + /// restricted to properties whose name is in this set; an empty set means no + /// filter — all properties are included + /// @return the root graph node with all resolved (and filtered) relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, - int depth, boolean includeProperties) { + int depth, boolean includeProperties, Set relationFilter, + Set propertyFilter) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); entityTemplateValidationService.validateTemplateExists(templateIdentifier); @@ -83,7 +96,8 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. Set visitedNodeIds = new HashSet<>(); - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, relationFilter, + propertyFilter, visitedNodeIds); } /// Builds a graph node from a pre-loaded entity map (no database calls). @@ -97,7 +111,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId /// inbound scanning re-expanding the same nodes at every depth level. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, int remainingDepth, boolean includeProperties, - Set visitedNodeIds) { + Set relationFilter, Set propertyFilter, Set visitedNodeIds) { Entity entity = entityMap.get(key); if (entity == null) { return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), @@ -111,7 +125,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // nodes. var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); if (!visitedNodeIds.add(nodeId)) { - List stubProperties = includeProperties ? entity.properties() : List.of(); + List stubProperties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), stubProperties, List.of(), List.of()); } @@ -119,22 +133,26 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Depth exhausted — return a leaf with no relations but still carry properties // so the deepest reachable entities expose their data when include_data=true. if (remainingDepth <= 0) { - List leafProperties = includeProperties ? entity.properties() : List.of(); + List leafProperties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), leafProperties, List.of(), List.of()); } List outboundRelations = entity.relations().stream() - .map(relation -> new EntityGraphRelation(relation.name(), relation.targetEntityIdentifiers() - .stream().map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), - entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) - .toList())) + .filter(relation -> relationFilter.isEmpty() || relationFilter.contains(relation.name())) + .map(relation -> new EntityGraphRelation(relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), entityMap, + remainingDepth - 1, includeProperties, relationFilter, propertyFilter, + visitedNodeIds)) + .toList())) .toList(); List inboundRelations = buildRelationsAsTargetFromMap(entity.identifier(), - entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); + entityMap, remainingDepth - 1, includeProperties, relationFilter, propertyFilter, + visitedNodeIds); - List properties = includeProperties ? entity.properties() : List.of(); + List properties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); } @@ -155,13 +173,14 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, /// depths. private List buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, int remainingDepth, boolean includeProperties, - Set visitedNodeIds) { + Set relationFilter, Set propertyFilter, Set visitedNodeIds) { Map> sourcesByRelationName = new HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { Entity sourceEntity = entry.getValue(); for (Relation relation : sourceEntity.relations()) { - if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier) + && (relationFilter.isEmpty() || relationFilter.contains(relation.name()))) { sourcesByRelationName.computeIfAbsent(relation.name(), k -> new ArrayList<>()) .add(entry.getKey()); } @@ -170,8 +189,23 @@ private List buildRelationsAsTargetFromMap(String targetIde return sourcesByRelationName.entrySet().stream() .map(e -> new EntityGraphRelation(e.getKey(), - e.getValue().stream().map(sourceKey -> buildGraphNode(sourceKey, entityMap, - remainingDepth, includeProperties, visitedNodeIds)).toList())) + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, relationFilter, propertyFilter, visitedNodeIds)) + .toList())) .toList(); } + + /// Returns the entity's properties filtered by [propertyFilter] when active, + /// or an empty list when [includeProperties] is false. + private List resolveProperties(Entity entity, boolean includeProperties, + Set propertyFilter) { + if (!includeProperties) { + return List.of(); + } + if (propertyFilter.isEmpty()) { + return entity.properties(); + } + return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index a90639b..5aa6a68 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -85,8 +85,8 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, depth, includeData); + entityIdentifier, depth, includeData, relationFilter, propertyFilter); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index fd96646..83d5785 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -12,6 +12,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; @@ -30,6 +31,8 @@ /// - A `SequencedSet` of visited node IDs prevents infinite loops in cyclic graphs. /// - A `Set` of edge signatures (`source|target|label`) deduplicates edges that would /// otherwise be emitted twice when both sides of a relation are traversed. +/// - Filtering (relation names, property names) is a domain concern handled upstream by +/// [EntityGraphService]; this mapper only flattens the tree it receives. public final class EntityGraphFlatDtoOutMapper { private EntityGraphFlatDtoOutMapper() { @@ -46,18 +49,12 @@ private record TraversalState(SequencedSet nodes, /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// + /// The domain graph passed here is already filtered by the service layer; + /// this method only performs structural flattening. + /// /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set - /// are emitted, - /// and nodes not referenced by any remaining edge are pruned; - /// an empty set means no filter — all edge types and nodes are emitted - /// @param propertyFilter when non-empty, only properties whose name is in this - /// set appear - /// in each node's `data` field; - /// an empty set means no filter — all properties are included /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, - Set propertyFilter) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -68,32 +65,12 @@ public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges new AtomicInteger(0)); // edgeCounter - traverse(root, state, relationFilter, propertyFilter); - - // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. Without this step, nodes reachable via non-filtered edges - // would - // appear in the node list despite having no visible edges. - List finalNodes; - if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(state.nodes()); - } else { - // Collect all node IDs referenced by the filtered edges only. - // The root receives no special treatment: if it has no matching edges - // it is pruned just like any other disconnected node. - Set referencedNodeIds = new HashSet<>(); - for (var edge : state.edges()) { - referencedNodeIds.add(edge.source()); - referencedNodeIds.add(edge.target()); - } - finalNodes = state.nodes().stream().filter(n -> referencedNodeIds.contains(n.id())).toList(); - } + traverse(root, state); - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); + return new EntityGraphFlatDtoOut(List.copyOf(state.nodes()), List.copyOf(state.edges())); } - private static void traverse(EntityGraphNode node, TraversalState state, - Set relationFilter, Set propertyFilter) { + private static void traverse(EntityGraphNode node, TraversalState state) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); @@ -103,20 +80,14 @@ private static void traverse(EntityGraphNode node, TraversalState state, } state.nodes().add(new EntityGraphNodeFlatDtoOut(nodeId, node.name(), node.templateIdentifier(), - node.identifier(), toDataMap(node, propertyFilter))); + node.identifier(), toDataMap(node))); - // Traverse outbound relations: emit edge from currentNode → target only when - // the - // relation type matches the filter (or no filter is active). Nodes are always - // traversed so that deeper nodes remain reachable regardless of edge - // visibility. + // Traverse outbound relations: emit edge from currentNode → target. for (EntityGraphRelation relation : node.relations()) { for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, nodeId, targetId, relation.name()); - } - traverse(target, state, relationFilter, propertyFilter); + addEdge(state, nodeId, targetId, relation.name()); + traverse(target, state); } } @@ -127,10 +98,8 @@ private static void traverse(EntityGraphNode node, TraversalState state, for (EntityGraphRelation relation : node.relationsAsTarget()) { for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, sourceId, nodeId, relation.name()); - } - traverse(source, state, relationFilter, propertyFilter); + addEdge(state, sourceId, nodeId, relation.name()); + traverse(source, state); } } } @@ -159,23 +128,14 @@ private static String nodeId(String templateIdentifier, String identifier) { /// Converts a node's property list to a name→value map for the `data` field. /// - /// When [propertyFilter] is non-empty, only entries whose name is contained in - /// the - /// filter are included. Returns an empty map when there are no matching - /// properties; - /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted - /// from - /// the JSON output. + /// The domain service has already applied any property filter; this method + /// simply converts whatever properties the node carries into the map format + /// expected by the DTO. /// - /// @param node the graph node whose properties are converted - /// @param propertyFilter when non-empty, restricts which properties appear in - /// the map; - /// an empty set means all properties are included - private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { - var stream = node.properties().stream(); - if (!propertyFilter.isEmpty()) { - stream = stream.filter(p -> propertyFilter.contains(p.name())); - } - return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); + /// Returns an empty map when there are no properties; the DTO's + /// @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from the + /// JSON output. + private static Map toDataMap(EntityGraphNode node) { + return node.properties().stream().collect(Collectors.toMap(p -> p.name(), p -> p.value())); } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 768efc8..66419a5 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -25,11 +26,13 @@ import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityGraphService Tests") @@ -41,6 +44,9 @@ class EntityGraphServiceTest { @Mock private EntityGraphRepositoryPort entityGraphRepositoryPort; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @InjectMocks private EntityGraphService entityGraphService; @@ -86,8 +92,8 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) - .isInstanceOf(EntityNotFoundException.class); + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, + Set.of(), Set.of())).isInstanceOf(EntityNotFoundException.class); verify(entityGraphRepositoryPort, never()).findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); @@ -107,7 +113,8 @@ void shouldReturnLeafNodeWhenNoRelations() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -132,7 +139,8 @@ void shouldResolveOutboundRelations() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); @@ -150,7 +158,8 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relations()).hasSize(1); EntityGraphNode fallback = result.relations().get(0).targets().get(0); @@ -174,7 +183,8 @@ void shouldResolveInboundRelations() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relationsAsTarget()).hasSize(1); assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); @@ -196,7 +206,7 @@ void shouldClampDepthBelowOne() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of()); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } @@ -209,7 +219,7 @@ void shouldClampDepthAboveTen() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of()); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); } @@ -234,7 +244,8 @@ void shouldReturnLeafNodeAtDepthBoundary() { stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, key("infra", "server-1"), server)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); assertThat(postgresNode.identifier()).isEqualTo("postgres"); @@ -262,7 +273,8 @@ void shouldResolveMultipleNamedRelations() { stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, key(TEMPLATE, "auth"), auth)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relations()).hasSize(2); assertThat(result.relations().stream().map(EntityGraphRelation::name)) @@ -272,37 +284,133 @@ void shouldResolveMultipleNamedRelations() { // ======================== @Nested - @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") - class FullGraphReturned { + @DisplayName("Relation Filtering") + class RelationFiltering { @Test - @DisplayName("Should return all edges regardless of relation type (no filtering in service)") - void shouldReturnAllEdgesWithoutFiltering() { - // A --(depends-on)--> B --(owns)--> C - // The service must return both edges — the mapper will filter them. + @DisplayName("Should include only relations matching the relation filter") + void shouldFilterRelationsByName() { + // A --(depends-on)--> B, A --(owns)--> C; filter keeps only 'depends-on' Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("depends-on", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("owns", TEMPLATE, "c"))); + List.of(relation("depends-on", TEMPLATE, "b"), relation("owns", TEMPLATE, "c"))); + Entity b = entity(TEMPLATE, "b", "B"); Entity c = entity(TEMPLATE, "c", "C"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) .thenReturn(Optional.of(a)); stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, + Set.of("depends-on"), Set.of()); - // Root A has one outbound "depends-on" edge → B assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + } - // B (at depth 1) has one outbound "owns" edge → C - EntityGraphNode nodeB = result.relations().get(0).targets().get(0); - assertThat(nodeB.identifier()).isEqualTo("b"); - assertThat(nodeB.relations()).hasSize(1); - assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); - assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + @Test + @DisplayName("Should return all relations when relation filter is empty") + void shouldReturnAllRelationsWhenFilterIsEmpty() { + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"), relation("owns", TEMPLATE, "c"))); + Entity b = entity(TEMPLATE, "b", "B"); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of(), + Set.of()); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("depends-on", "owns"); + } + + @Test + @DisplayName("Should filter inbound relations by name") + void shouldFilterInboundRelationsByName() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + Entity unrelated = entityWithRelations(TEMPLATE, "unrelated", "Unrelated", + List.of(relation("owns", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer, + key(TEMPLATE, "unrelated"), unrelated)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of("depends-on"), Set.of()); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Property Filtering") + class PropertyFiltering { + + private Entity entityWithProperties(String templateIdentifier, String identifier, String name, + List properties) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + List.of()); + } + + @Test + @DisplayName("Should include only properties matching the property filter") + void shouldFilterPropertiesByName() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + var propOwner = new Property(UUID.randomUUID(), "owner", "team-a"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", + List.of(propEnv, propOwner)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of("env")); + + assertThat(result.properties()).hasSize(1); + assertThat(result.properties().get(0).name()).isEqualTo("env"); + } + + @Test + @DisplayName("Should return all properties when property filter is empty") + void shouldReturnAllPropertiesWhenFilterIsEmpty() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + var propOwner = new Property(UUID.randomUUID(), "owner", "team-a"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", + List.of(propEnv, propOwner)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of()); + + assertThat(result.properties()).hasSize(2); + } + + @Test + @DisplayName("Should return empty properties when includeProperties is false regardless of filter") + void shouldReturnEmptyPropertiesWhenIncludePropertiesIsFalse() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", List.of(propEnv)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of("env")); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + assertThat(result.properties()).isEmpty(); } } @@ -327,7 +435,8 @@ void shouldNotExplodeAtMaxDepthWithSmallGraph() { // Must complete instantly — any OOM or StackOverflow here means the guard is // missing. - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false, Set.of(), + Set.of()); assertThat(result.identifier()).isEqualTo("a"); assertThat(result.relations()).hasSize(1); @@ -344,7 +453,8 @@ void shouldReturnStubLeafForRevisitedNode() { .thenReturn(Optional.of(a)); stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false, Set.of(), + Set.of()); // A → B is resolved assertThat(result.relations()).hasSize(1); From 1929d865d1c6caaed568912a403e55385eb63145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 16:19:54 +0200 Subject: [PATCH 26/27] feat(core): add a entity graph service and endpoint --- ...EntityTemplateIdentifierCannotChangeException.java | 1 - .../idp_core/domain/model/entity/Property.java | 4 ++-- .../persistence/repository/JpaEntityRepository.java | 11 +++++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java index cd31885..b6bb002 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -6,7 +6,6 @@ import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; - /// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. /// /// **Why this exception exists:** diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 0ae129d..c71c02e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -4,12 +4,12 @@ import java.util.UUID; +import jakarta.validation.constraints.NotBlank; + import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import jakarta.validation.constraints.NotBlank; - /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 762f327..1ddc3bb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -163,15 +163,14 @@ void deletePropertiesByTemplateIdentifierAndPropertyName( @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames + AND r2.name IN :relationNames ) """) - - void deleteRelationsByTemplateIdentifierAndRelationName( + void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); } From ad8296b1661dc7f5330eb9b4d9d0590a1479a935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 16:28:50 +0200 Subject: [PATCH 27/27] feat(core): add a entity graph service and endpoint --- .github/instructions/domain.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index e1f34a6..8b5a872 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -211,4 +211,4 @@ domain/ - Organize exceptions by aggregate/subdomain (for example, `entity/`, `entity_template/`, `property/`) - Each exception class should have a clear, descriptive name that follows the naming conventions above -- Keep exception hierarchy flat — avoid deep inheritance trees +- Keep exception hierarchy flat. Avoid deep inheritance trees