From d807c697d72b8e25806481443238f01f242185bf Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Fri, 24 Apr 2026 14:05:10 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 +}